removed timeline anchor

This commit is contained in:
Maze Winther 2026-01-30 15:40:42 +01:00
parent d719460f32
commit 1fd74904e9
20 changed files with 560 additions and 472 deletions

0
.cursor/debug.log Normal file
View File

View File

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

View File

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

View File

@ -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<HTMLDivElement>;
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 (
<div className="relative h-4 flex-1 overflow-hidden">
<ScrollArea className="scrollbar-hidden w-full" ref={bookmarksScrollRef}>
<button
className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
style={{
width: `${dynamicTimelineWidth}px`,
}}
aria-label="Timeline ruler"
type="button"
onWheel={handleWheel}
onClick={handleTimelineContentClick}
onMouseDown={(event) => {
handleRulerMouseDown(event);
handleRulerTrackingMouseDown(event);
}}
>
{activeScene.bookmarks.map((time: number) => (
<TimelineBookmark
key={`bookmark-row-${time}`}
time={time}
zoomLevel={zoomLevel}
/>
))}
</button>
</ScrollArea>
<button
className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
style={{
width: `${dynamicTimelineWidth}px`,
}}
aria-label="Timeline ruler"
type="button"
onWheel={handleWheel}
onClick={handleTimelineContentClick}
onMouseDown={(event) => {
handleRulerMouseDown(event);
handleRulerTrackingMouseDown(event);
}}
>
{activeScene.bookmarks.map((time: number) => (
<TimelineBookmark
key={`bookmark-row-${time}`}
time={time}
zoomLevel={zoomLevel}
/>
))}
</button>
</div>
);
}

View File

@ -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 (
<div
className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5"
style={{ top: `${y}px` }}
style={{ top: `${lineTop}px` }}
/>
);
}

View File

@ -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<HTMLDivElement>(null);
const timelineHeaderRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
const tracksContainerRef = useRef<HTMLDivElement>(null);
const rulerScrollRef = useRef<HTMLDivElement>(null);
const tracksScrollRef = useRef<HTMLDivElement>(null);
const trackLabelsRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
const bookmarksScrollRef = useRef<HTMLDivElement>(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 (
<section
className={
@ -206,18 +216,6 @@ export function Timeline() {
className="relative flex flex-1 flex-col overflow-hidden"
ref={timelineRef}
>
<TimelinePlayhead
zoomLevel={zoomLevel}
rulerRef={rulerRef}
rulerScrollRef={rulerScrollRef}
tracksScrollRef={tracksScrollRef}
trackLabelsRef={trackLabelsRef}
timelineRef={timelineRef}
playheadRef={playheadRef}
isSnappingToPlayhead={
showSnapIndicator && currentSnapPoint?.type === "playhead"
}
/>
<SnapIndicator
snapPoint={currentSnapPoint}
zoomLevel={zoomLevel}
@ -227,106 +225,75 @@ export function Timeline() {
tracksScrollRef={tracksScrollRef}
isVisible={showSnapIndicator}
/>
<div className="bg-panel sticky top-0 z-10 flex flex-col">
<div className="flex">
<div className="bg-panel flex h-4 w-28 shrink-0 items-center justify-between border-r px-3">
<span className="opacity-0">.</span>
</div>
<TimelineRuler
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
rulerRef={rulerRef}
rulerScrollRef={rulerScrollRef}
handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown}
handleRulerMouseDown={handlePlayheadRulerMouseDown}
/>
</div>
<div className="flex">
<div className="bg-panel flex h-4 w-28 shrink-0 items-center justify-between border-r px-3">
<span className="opacity-0">.</span>
</div>
<TimelineBookmarksRow
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
bookmarksScrollRef={bookmarksScrollRef}
handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown}
handleRulerMouseDown={handlePlayheadRulerMouseDown}
/>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
{tracks.length > 0 && (
<div
ref={trackLabelsRef}
className="bg-panel z-100 w-28 shrink-0 overflow-y-auto border-r"
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP }}
>
<ScrollArea className="size-full" ref={trackLabelsScrollRef}>
<div className="flex flex-col gap-1">
{tracks.map((track) => (
<div
key={track.id}
className="group flex items-center px-3"
style={{
height: `${getTrackHeight({ type: track.type })}px`,
}}
>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
{process.env.NODE_ENV === "development" &&
isMainTrack(track) && (
<div className="bg-red-500 size-1.5 rounded-full" />
)}
{canTracktHaveAudio(track) && (
<TrackToggleIcon
isOff={track.muted}
icons={{
on: VolumeHighIcon,
off: VolumeOffIcon,
}}
onClick={() =>
editor.timeline.toggleTrackMute({
trackId: track.id,
})
}
/>
)}
{canTrackBeHidden(track) && (
<TrackToggleIcon
isOff={track.hidden}
icons={{
on: ViewIcon,
off: ViewOffSlashIcon,
}}
onClick={() =>
editor.timeline.toggleTrackVisibility({
trackId: track.id,
})
}
/>
)}
<TrackIcon track={track} />
</div>
</div>
))}
</div>
</ScrollArea>
<div className="bg-panel flex w-28 shrink-0 flex-col border-r">
<div className="bg-panel flex h-4 items-center justify-between px-3">
<span className="opacity-0">.</span>
</div>
)}
<div className="bg-panel flex h-4 items-center justify-between px-3">
<span className="opacity-0">.</span>
</div>
{tracks.length > 0 && (
<div
ref={trackLabelsRef}
className="bg-panel flex-1 overflow-y-auto"
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }}
>
<ScrollArea className="size-full" ref={trackLabelsScrollRef}>
<div className="flex flex-col gap-1">
{tracks.map((track) => (
<div
key={track.id}
className="group flex items-center px-3"
style={{
height: `${getTrackHeight({ type: track.type })}px`,
}}
>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
{process.env.NODE_ENV === "development" &&
isMainTrack(track) && (
<div className="bg-red-500 size-1.5 rounded-full" />
)}
{canTracktHaveAudio(track) && (
<TrackToggleIcon
isOff={track.muted}
icons={{
on: VolumeHighIcon,
off: VolumeOffIcon,
}}
onClick={() =>
editor.timeline.toggleTrackMute({
trackId: track.id,
})
}
/>
)}
{canTrackBeHidden(track) && (
<TrackToggleIcon
isOff={track.hidden}
icons={{
on: ViewIcon,
off: ViewOffSlashIcon,
}}
onClick={() =>
editor.timeline.toggleTrackVisibility({
trackId: track.id,
})
}
/>
)}
<TrackIcon track={track} />
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
</div>
<div
className="relative flex-1 overflow-hidden"
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP }}
onWheel={(e) => {
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}
>
<SelectionBox
@ -339,15 +306,16 @@ export function Timeline() {
dropTarget={dropTarget}
tracks={timeline.getTracks()}
isVisible={isDragOver}
headerHeight={timelineHeaderHeight}
/>
<DragLine
dropTarget={dragDropTarget}
tracks={timeline.getTracks()}
isVisible={dragState.isDragging}
headerHeight={timelineHeaderHeight}
/>
<ScrollArea
className="size-full"
className="size-full overflow-y-hidden"
ref={tracksScrollRef}
onMouseDown={(event) => {
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();
}}
>
<div
className="relative flex-1"
className="relative"
style={{
height: `${Math.max(
tracksContainerHeight.min,
Math.min(
tracksContainerHeight.max,
getTotalTracksHeight({ tracks }),
),
)}px`,
width: `${dynamicTimelineWidth}px`,
}}
>
{tracks.length === 0 ? (
<div />
) : (
tracks.map((track, index) => (
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild>
<div
className="absolute right-0 left-0"
style={{
top: `${getCumulativeHeightBefore({
tracks,
trackIndex: index,
})}px`,
height: `${getTrackHeight({ type: track.type })}px`,
}}
>
<TimelineTrackContent
track={track}
zoomLevel={zoomLevel}
dragState={dragState}
rulerScrollRef={rulerScrollRef}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
onSnapPointChange={handleSnapPointChange}
onResizeStateChange={handleResizeStateChange}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
onTrackMouseDown={handleSelectionMouseDown}
shouldIgnoreClick={shouldIgnoreClick}
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-200 w-40">
<ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
onClick={(e) => {
e.stopPropagation();
invokeAction("paste-copied");
}}
>
Paste elements
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
timeline.toggleTrackMute({
trackId: track.id,
});
}}
>
<HugeiconsIcon icon={VolumeHighIcon} />
<span>
{canTracktHaveAudio(track) && track.muted
? "Unmute track"
: "Mute track"}
</span>
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
timeline.toggleTrackVisibility({
trackId: track.id,
});
}}
>
<HugeiconsIcon icon={ViewIcon} />
<span>
{canTrackBeHidden(track) && track.hidden
? "Show track"
: "Hide track"}
</span>
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
timeline.removeTrack({
trackId: track.id,
});
}}
variant="destructive"
>
<HugeiconsIcon icon={Delete02Icon} />
Delete track
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))
)}
<div
ref={timelineHeaderRef}
className="bg-panel sticky top-0 z-30 flex flex-col"
>
<TimelineRuler
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
rulerRef={rulerRef}
handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown}
handleRulerMouseDown={handlePlayheadRulerMouseDown}
/>
<TimelineBookmarksRow
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown}
handleRulerMouseDown={handlePlayheadRulerMouseDown}
/>
</div>
<TimelinePlayhead
zoomLevel={zoomLevel}
rulerRef={rulerRef}
rulerScrollRef={tracksScrollRef}
tracksScrollRef={tracksScrollRef}
timelineRef={timelineRef}
playheadRef={playheadRef}
isSnappingToPlayhead={
showSnapIndicator && currentSnapPoint?.type === "playhead"
}
/>
<div
className="relative"
style={{
height: `${Math.max(
tracksContainerHeight.min,
Math.min(
tracksContainerHeight.max,
getTotalTracksHeight({ tracks }),
),
)}px`,
}}
>
{tracks.length === 0 ? (
<div />
) : (
tracks.map((track, index) => (
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild>
<div
className="absolute right-0 left-0"
style={{
top: `${getCumulativeHeightBefore({
tracks,
trackIndex: index,
})}px`,
height: `${getTrackHeight({
type: track.type,
})}px`,
}}
>
<TimelineTrackContent
track={track}
zoomLevel={zoomLevel}
dragState={dragState}
rulerScrollRef={tracksScrollRef}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
onSnapPointChange={handleSnapPointChange}
onResizeStateChange={handleResizeStateChange}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
onTrackMouseDown={handleSelectionMouseDown}
shouldIgnoreClick={shouldIgnoreClick}
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-200 w-40">
<ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
onClick={(e) => {
e.stopPropagation();
invokeAction("paste-copied");
}}
>
Paste elements
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
timeline.toggleTrackMute({
trackId: track.id,
});
}}
>
<HugeiconsIcon icon={VolumeHighIcon} />
<span>
{canTracktHaveAudio(track) && track.muted
? "Unmute track"
: "Mute track"}
</span>
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
timeline.toggleTrackVisibility({
trackId: track.id,
});
}}
>
<HugeiconsIcon icon={ViewIcon} />
<span>
{canTrackBeHidden(track) && track.hidden
? "Show track"
: "Hide track"}
</span>
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
timeline.removeTrack({
trackId: track.id,
});
}}
variant="destructive"
>
<HugeiconsIcon icon={Delete02Icon} />
Delete track
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))
)}
</div>
</div>
</ScrollArea>
</div>

View File

@ -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<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
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<HTMLDivElement>(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<HTMLDivElement>,

View File

@ -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<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
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={() => {}}
>
<ScrollArea className="scrollbar-hidden w-full" ref={rulerScrollRef}>
<div
role="none"
ref={rulerRef}
className="relative h-4 cursor-default select-none"
style={{
width: `${dynamicTimelineWidth}px`,
}}
onMouseDown={handleRulerMouseDown}
>
{timelineTicks}
</div>
</ScrollArea>
<div
role="none"
ref={rulerRef}
className="relative h-4 cursor-default select-none"
style={{
width: `${dynamicTimelineWidth}px`,
}}
onMouseDown={handleRulerMouseDown}
>
{timelineTicks}
</div>
</div>
);
}

View File

@ -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<TrackType, React.ReactNode> = {

View File

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

View File

@ -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,
);

View File

@ -28,6 +28,7 @@ interface UseElementInteractionProps {
timelineRef: RefObject<HTMLDivElement | null>;
tracksContainerRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
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<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
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(() => {

View File

@ -1,15 +1,15 @@
import { useEffect, useRef } from "react";
interface UseScrollSyncProps {
rulerScrollRef: React.RefObject<HTMLDivElement>;
rulerScrollRef?: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsScrollRef?: React.RefObject<HTMLDivElement>;
bookmarksScrollRef?: React.RefObject<HTMLDivElement>;
}
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);
}
};

View File

@ -18,11 +18,13 @@ import type { MediaDragData, StickerDragData } from "@/types/drag";
interface UseTimelineDragDropProps {
containerRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
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,
],
);

View File

@ -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(() => {

View File

@ -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,
],
);

View File

@ -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<HTMLDivElement>;
rulerScrollRef: RefObject<HTMLDivElement>;
}
@ -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<ReturnType<typeof setTimeout> | 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) => {

View File

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

View File

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

View File

@ -34,6 +34,7 @@ export interface TProjectSettings {
export interface TTimelineViewState {
zoomLevel: number;
scrollLeft: number;
playheadTime: number;
}
export interface TProject {