Skip to content

Commit ad794fa

Browse files
committed
Merge branch 'timeline-zoom'
2 parents 7d59143 + 9ca32e5 commit ad794fa

File tree

6 files changed

+99
-30
lines changed

6 files changed

+99
-30
lines changed

apps/web/src/components/editor/timeline/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,11 @@ export function Timeline() {
423423
onResizeStateChange={handleResizeStateChange}
424424
onElementMouseDown={handleElementMouseDown}
425425
onElementClick={handleElementClick}
426-
onTrackMouseDown={handleSelectionMouseDown}
426+
onTrackMouseDown={(event) => {
427+
handleSelectionMouseDown(event);
428+
handleTracksMouseDown(event);
429+
}}
430+
onTrackClick={handleTracksClick}
427431
shouldIgnoreClick={shouldIgnoreClick}
428432
/>
429433
</div>

apps/web/src/components/editor/timeline/timeline-toolbar.tsx

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { Slider } from "@/components/ui/slider";
1717
import { formatTimeCode } from "@/lib/time";
1818
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
19+
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
1920
import { EditableTimecode } from "@/components/editable-timecode";
2021
import { ScenesView } from "../scenes-view";
2122
import { type TAction, invokeAction } from "@/lib/actions";
@@ -54,9 +55,9 @@ export function TimelineToolbar({
5455
direction === "in"
5556
? Math.min(
5657
TIMELINE_CONSTANTS.ZOOM_MAX,
57-
zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP,
58+
zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
5859
)
59-
: Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP);
60+
: Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
6061
setZoomLevel({ zoom: newZoomLevel });
6162
};
6263

@@ -127,17 +128,13 @@ function ToolbarLeftSection() {
127128
<ToolbarButton
128129
icon={<HugeiconsIcon icon={ScissorIcon} />}
129130
tooltip="Split element"
130-
onClick={({ event }) =>
131-
handleAction({ action: "split", event })
132-
}
131+
onClick={({ event }) => handleAction({ action: "split", event })}
133132
/>
134133

135134
<ToolbarButton
136135
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
137136
tooltip="Split left"
138-
onClick={({ event }) =>
139-
handleAction({ action: "split-left", event })
140-
}
137+
onClick={({ event }) => handleAction({ action: "split-left", event })}
141138
/>
142139

143140
<ToolbarButton
@@ -302,23 +299,25 @@ function ToolbarRightSection({
302299
type="button"
303300
onClick={() => onZoom({ direction: "out" })}
304301
>
305-
<HugeiconsIcon icon={SearchAddIcon} />
302+
<HugeiconsIcon icon={SearchMinusIcon} />
306303
</Button>
307304
<Slider
308-
className="w-24"
309-
value={[zoomLevel]}
310-
onValueChange={(values) => onZoomChange(values[0])}
311-
min={minZoom}
312-
max={TIMELINE_CONSTANTS.ZOOM_MAX}
313-
step={TIMELINE_CONSTANTS.ZOOM_STEP}
305+
className="w-28"
306+
value={[zoomToSlider({ zoomLevel, minZoom })]}
307+
onValueChange={(values) =>
308+
onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom }))
309+
}
310+
min={0}
311+
max={1}
312+
step={0.005}
314313
/>
315314
<Button
316315
variant="text"
317316
size="icon"
318317
type="button"
319318
onClick={() => onZoom({ direction: "in" })}
320319
>
321-
<HugeiconsIcon icon={SearchMinusIcon} />
320+
<HugeiconsIcon icon={SearchAddIcon} />
322321
</Button>
323322
</div>
324323
</div>

apps/web/src/components/editor/timeline/timeline-track.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ interface TimelineTrackContentProps {
3131
track: TimelineTrack;
3232
}) => void;
3333
onTrackMouseDown?: (event: React.MouseEvent) => void;
34+
onTrackClick?: (event: React.MouseEvent) => void;
3435
shouldIgnoreClick?: () => boolean;
3536
}
3637

@@ -46,6 +47,7 @@ export function TimelineTrackContent({
4647
onElementMouseDown,
4748
onElementClick,
4849
onTrackMouseDown,
50+
onTrackClick,
4951
shouldIgnoreClick,
5052
}: TimelineTrackContentProps) {
5153
const editor = useEditor();
@@ -68,11 +70,13 @@ export function TimelineTrackContent({
6870
return (
6971
<button
7072
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
71-
onClick={() => {
73+
onClick={(event) => {
7274
if (shouldIgnoreClick?.()) return;
7375
clearElementSelection();
76+
onTrackClick?.(event);
7477
}}
7578
onMouseDown={(event) => {
79+
event.preventDefault();
7680
onTrackMouseDown?.(event);
7781
}}
7882
type="button"

apps/web/src/constants/timeline-constants.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,11 @@ export const TRACK_GAP = 4;
3535
export const TIMELINE_CONSTANTS = {
3636
PIXELS_PER_SECOND: 50,
3737
DEFAULT_ELEMENT_DURATION: 5,
38-
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // padding ahead
3938
PADDING_TOP_PX: 0,
40-
ZOOM_LEVELS: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 15, 20, 30, 50],
4139
ZOOM_MIN: 0.1,
4240
ZOOM_MAX: 100,
43-
ZOOM_STEP: 0.1,
41+
ZOOM_BUTTON_FACTOR: 1.7,
42+
ZOOM_ANCHOR_PLAYHEAD_THRESHOLD: 0.15,
4443
} as const;
4544

4645
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {

apps/web/src/hooks/timeline/use-timeline-zoom.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "react";
1010
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
1111
import { useEditor } from "@/hooks/use-editor";
12+
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
1213

1314
interface UseTimelineZoomProps {
1415
containerRef: RefObject<HTMLDivElement | null>;
@@ -122,18 +123,47 @@ export function useTimelineZoom({
122123
if (previousZoom === zoomLevel) return;
123124

124125
const scrollElement = tracksScrollRef.current;
125-
if (scrollElement) {
126-
editor.project.setTimelineViewState({
127-
viewState: {
128-
zoomLevel,
129-
scrollLeft: scrollElement.scrollLeft,
130-
playheadTime: editor.playback.getCurrentTime(),
131-
},
132-
});
126+
if (!scrollElement) {
127+
previousZoomRef.current = zoomLevel;
128+
return;
129+
}
130+
131+
const currentScrollLeft = scrollElement.scrollLeft;
132+
const playheadTime = editor.playback.getCurrentTime();
133+
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
134+
135+
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
136+
const playheadPixelsBefore =
137+
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom;
138+
const playheadPixelsAfter =
139+
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
140+
141+
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
142+
const newScrollLeft = playheadPixelsAfter - viewportOffset;
143+
144+
const maxScrollLeft =
145+
scrollElement.scrollWidth - scrollElement.clientWidth;
146+
const clampedScrollLeft = Math.max(
147+
0,
148+
Math.min(maxScrollLeft, newScrollLeft),
149+
);
150+
151+
scrollElement.scrollLeft = clampedScrollLeft;
152+
if (rulerScrollRef.current) {
153+
rulerScrollRef.current.scrollLeft = clampedScrollLeft;
154+
}
133155
}
134156

135157
previousZoomRef.current = zoomLevel;
136-
}, [zoomLevel, editor, tracksScrollRef]);
158+
159+
editor.project.setTimelineViewState({
160+
viewState: {
161+
zoomLevel,
162+
scrollLeft: scrollElement.scrollLeft,
163+
playheadTime,
164+
},
165+
});
166+
}, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]);
137167

138168
// biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
139169
const saveScrollPosition = useCallback(() => {

apps/web/src/lib/timeline/zoom-utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,36 @@ export function getZoomPercent({
5151
}): number {
5252
return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom);
5353
}
54+
55+
/**
56+
* convert linear slider position (0-1) to exponential zoom level.
57+
* at low slider values, zoom changes are small. at high values, changes are large.
58+
*/
59+
export function sliderToZoom({
60+
sliderPosition,
61+
minZoom,
62+
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
63+
}: {
64+
sliderPosition: number;
65+
minZoom: number;
66+
maxZoom?: number;
67+
}): number {
68+
const clampedPosition = Math.max(0, Math.min(1, sliderPosition));
69+
return minZoom * (maxZoom / minZoom) ** clampedPosition;
70+
}
71+
72+
/**
73+
* convert exponential zoom level to linear slider position (0-1).
74+
*/
75+
export function zoomToSlider({
76+
zoomLevel,
77+
minZoom,
78+
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
79+
}: {
80+
zoomLevel: number;
81+
minZoom: number;
82+
maxZoom?: number;
83+
}): number {
84+
const clampedZoom = Math.max(minZoom, Math.min(maxZoom, zoomLevel));
85+
return Math.log(clampedZoom / minZoom) / Math.log(maxZoom / minZoom);
86+
}

0 commit comments

Comments
 (0)