From c3f0d640ef366fe4d94ff995ee8fe5ef282b9c1e Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 2 Feb 2026 18:11:12 +0100 Subject: [PATCH 1/5] feat: exponential zoom --- .../editor/timeline/timeline-toolbar.tsx | 31 ++++++++--------- apps/web/src/lib/timeline/zoom-utils.ts | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index 822bb438..33cf83ae 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -16,6 +16,7 @@ import { import { Slider } from "@/components/ui/slider"; import { formatTimeCode } from "@/lib/time"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; import { EditableTimecode } from "@/components/editable-timecode"; import { ScenesView } from "../scenes-view"; import { type TAction, invokeAction } from "@/lib/actions"; @@ -50,13 +51,11 @@ export function TimelineToolbar({ setZoomLevel: ({ zoom }: { zoom: number }) => void; }) { const handleZoom = ({ direction }: { direction: "in" | "out" }) => { + const zoomFactor = 1.25; const newZoomLevel = direction === "in" - ? Math.min( - TIMELINE_CONSTANTS.ZOOM_MAX, - zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP, - ) - : Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP); + ? Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, zoomLevel * zoomFactor) + : Math.max(minZoom, zoomLevel / zoomFactor); setZoomLevel({ zoom: newZoomLevel }); }; @@ -127,17 +126,13 @@ function ToolbarLeftSection() { } tooltip="Split element" - onClick={({ event }) => - handleAction({ action: "split", event }) - } + onClick={({ event }) => handleAction({ action: "split", event })} /> } tooltip="Split left" - onClick={({ event }) => - handleAction({ action: "split-left", event }) - } + onClick={({ event }) => handleAction({ action: "split-left", event })} /> onZoomChange(values[0])} - min={minZoom} - max={TIMELINE_CONSTANTS.ZOOM_MAX} - step={TIMELINE_CONSTANTS.ZOOM_STEP} + className="w-28" + value={[zoomToSlider({ zoomLevel, minZoom })]} + onValueChange={(values) => + onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom })) + } + min={0} + max={1} + step={0.005} /> onZoom({ direction: "in" })} > - + diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx index 692ccd65..e467de41 100644 --- a/apps/web/src/constants/timeline-constants.tsx +++ b/apps/web/src/constants/timeline-constants.tsx @@ -35,12 +35,10 @@ export const TRACK_GAP = 4; export const TIMELINE_CONSTANTS = { PIXELS_PER_SECOND: 50, DEFAULT_ELEMENT_DURATION: 5, - PLAYHEAD_LOOKAHEAD_SECONDS: 30, // padding ahead 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: 100, - ZOOM_STEP: 0.1, + ZOOM_BUTTON_FACTOR: 1.7, } as const; export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = { diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts index c4a64ed1..a299edb3 100644 --- a/apps/web/src/lib/timeline/zoom-utils.ts +++ b/apps/web/src/lib/timeline/zoom-utils.ts @@ -53,8 +53,8 @@ export function getZoomPercent({ } /** - * Convert linear slider position (0-1) to exponential zoom level. - * At low slider values, zoom changes are small. At high values, changes are large. + * convert linear slider position (0-1) to exponential zoom level. + * at low slider values, zoom changes are small. at high values, changes are large. */ export function sliderToZoom({ sliderPosition, From 1aec9c5cf5112fb3def79bec291540ff5c78e057 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 2 Feb 2026 19:15:25 +0100 Subject: [PATCH 3/5] fix comment --- apps/web/src/lib/timeline/zoom-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts index a299edb3..bbc0b6ae 100644 --- a/apps/web/src/lib/timeline/zoom-utils.ts +++ b/apps/web/src/lib/timeline/zoom-utils.ts @@ -70,7 +70,7 @@ export function sliderToZoom({ } /** - * Convert exponential zoom level to linear slider position (0-1). + * convert exponential zoom level to linear slider position (0-1). */ export function zoomToSlider({ zoomLevel, From 5120e2efa6c06ad02fd1039c2a94b40782a2ebfc Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 2 Feb 2026 19:42:35 +0100 Subject: [PATCH 4/5] feat: timeline zoom anchoring --- apps/web/src/constants/timeline-constants.tsx | 1 + .../src/hooks/timeline/use-timeline-zoom.ts | 48 +++++++++++++++---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx index e467de41..d6af3c3a 100644 --- a/apps/web/src/constants/timeline-constants.tsx +++ b/apps/web/src/constants/timeline-constants.tsx @@ -39,6 +39,7 @@ export const TIMELINE_CONSTANTS = { ZOOM_MIN: 0.1, ZOOM_MAX: 100, ZOOM_BUTTON_FACTOR: 1.7, + ZOOM_ANCHOR_PLAYHEAD_THRESHOLD: 0.15, } as const; export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = { diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts index 3d15b5c8..13024103 100644 --- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts +++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts @@ -9,6 +9,7 @@ import { } from "react"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useEditor } from "@/hooks/use-editor"; +import { zoomToSlider } from "@/lib/timeline/zoom-utils"; interface UseTimelineZoomProps { containerRef: RefObject; @@ -122,18 +123,47 @@ export function useTimelineZoom({ if (previousZoom === zoomLevel) return; const scrollElement = tracksScrollRef.current; - if (scrollElement) { - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime: editor.playback.getCurrentTime(), - }, - }); + if (!scrollElement) { + previousZoomRef.current = zoomLevel; + return; + } + + const currentScrollLeft = scrollElement.scrollLeft; + const playheadTime = editor.playback.getCurrentTime(); + const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); + + if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { + const playheadPixelsBefore = + playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom; + const playheadPixelsAfter = + playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + const viewportOffset = playheadPixelsBefore - currentScrollLeft; + const newScrollLeft = playheadPixelsAfter - viewportOffset; + + const maxScrollLeft = + scrollElement.scrollWidth - scrollElement.clientWidth; + const clampedScrollLeft = Math.max( + 0, + Math.min(maxScrollLeft, newScrollLeft), + ); + + scrollElement.scrollLeft = clampedScrollLeft; + if (rulerScrollRef.current) { + rulerScrollRef.current.scrollLeft = clampedScrollLeft; + } } previousZoomRef.current = zoomLevel; - }, [zoomLevel, editor, tracksScrollRef]); + + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime, + }, + }); + }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]); // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref const saveScrollPosition = useCallback(() => { From 9ca32e5e39d3a1a7eaf6086f9f0d14e10e03afbd Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 2 Feb 2026 19:44:27 +0100 Subject: [PATCH 5/5] fix: click empty on track to move playhead --- apps/web/src/components/editor/timeline/index.tsx | 6 +++++- apps/web/src/components/editor/timeline/timeline-track.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 7e2b2b66..271bad9b 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -423,7 +423,11 @@ export function Timeline() { onResizeStateChange={handleResizeStateChange} onElementMouseDown={handleElementMouseDown} onElementClick={handleElementClick} - onTrackMouseDown={handleSelectionMouseDown} + onTrackMouseDown={(event) => { + handleSelectionMouseDown(event); + handleTracksMouseDown(event); + }} + onTrackClick={handleTracksClick} shouldIgnoreClick={shouldIgnoreClick} /> diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 573bd4b5..47a9f282 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -31,6 +31,7 @@ interface TimelineTrackContentProps { track: TimelineTrack; }) => void; onTrackMouseDown?: (event: React.MouseEvent) => void; + onTrackClick?: (event: React.MouseEvent) => void; shouldIgnoreClick?: () => boolean; } @@ -46,6 +47,7 @@ export function TimelineTrackContent({ onElementMouseDown, onElementClick, onTrackMouseDown, + onTrackClick, shouldIgnoreClick, }: TimelineTrackContentProps) { const editor = useEditor(); @@ -68,11 +70,13 @@ export function TimelineTrackContent({ return (