Merge branch 'timeline-zoom'

This commit is contained in:
Maze Winther
2026-02-02 19:46:51 +01:00
6 changed files with 99 additions and 30 deletions
@@ -423,7 +423,11 @@ export function Timeline() {
onResizeStateChange={handleResizeStateChange} onResizeStateChange={handleResizeStateChange}
onElementMouseDown={handleElementMouseDown} onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick} onElementClick={handleElementClick}
onTrackMouseDown={handleSelectionMouseDown} onTrackMouseDown={(event) => {
handleSelectionMouseDown(event);
handleTracksMouseDown(event);
}}
onTrackClick={handleTracksClick}
shouldIgnoreClick={shouldIgnoreClick} shouldIgnoreClick={shouldIgnoreClick}
/> />
</div> </div>
@@ -16,6 +16,7 @@ import {
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { formatTimeCode } from "@/lib/time"; import { formatTimeCode } from "@/lib/time";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { EditableTimecode } from "@/components/editable-timecode"; import { EditableTimecode } from "@/components/editable-timecode";
import { ScenesView } from "../scenes-view"; import { ScenesView } from "../scenes-view";
import { type TAction, invokeAction } from "@/lib/actions"; import { type TAction, invokeAction } from "@/lib/actions";
@@ -54,9 +55,9 @@ export function TimelineToolbar({
direction === "in" direction === "in"
? Math.min( ? Math.min(
TIMELINE_CONSTANTS.ZOOM_MAX, TIMELINE_CONSTANTS.ZOOM_MAX,
zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP, zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
) )
: Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP); : Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
setZoomLevel({ zoom: newZoomLevel }); setZoomLevel({ zoom: newZoomLevel });
}; };
@@ -127,17 +128,13 @@ function ToolbarLeftSection() {
<ToolbarButton <ToolbarButton
icon={<HugeiconsIcon icon={ScissorIcon} />} icon={<HugeiconsIcon icon={ScissorIcon} />}
tooltip="Split element" tooltip="Split element"
onClick={({ event }) => onClick={({ event }) => handleAction({ action: "split", event })}
handleAction({ action: "split", event })
}
/> />
<ToolbarButton <ToolbarButton
icon={<HugeiconsIcon icon={AlignLeftIcon} />} icon={<HugeiconsIcon icon={AlignLeftIcon} />}
tooltip="Split left" tooltip="Split left"
onClick={({ event }) => onClick={({ event }) => handleAction({ action: "split-left", event })}
handleAction({ action: "split-left", event })
}
/> />
<ToolbarButton <ToolbarButton
@@ -302,15 +299,17 @@ function ToolbarRightSection({
type="button" type="button"
onClick={() => onZoom({ direction: "out" })} onClick={() => onZoom({ direction: "out" })}
> >
<HugeiconsIcon icon={SearchAddIcon} /> <HugeiconsIcon icon={SearchMinusIcon} />
</Button> </Button>
<Slider <Slider
className="w-24" className="w-28"
value={[zoomLevel]} value={[zoomToSlider({ zoomLevel, minZoom })]}
onValueChange={(values) => onZoomChange(values[0])} onValueChange={(values) =>
min={minZoom} onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom }))
max={TIMELINE_CONSTANTS.ZOOM_MAX} }
step={TIMELINE_CONSTANTS.ZOOM_STEP} min={0}
max={1}
step={0.005}
/> />
<Button <Button
variant="text" variant="text"
@@ -318,7 +317,7 @@ function ToolbarRightSection({
type="button" type="button"
onClick={() => onZoom({ direction: "in" })} onClick={() => onZoom({ direction: "in" })}
> >
<HugeiconsIcon icon={SearchMinusIcon} /> <HugeiconsIcon icon={SearchAddIcon} />
</Button> </Button>
</div> </div>
</div> </div>
@@ -31,6 +31,7 @@ interface TimelineTrackContentProps {
track: TimelineTrack; track: TimelineTrack;
}) => void; }) => void;
onTrackMouseDown?: (event: React.MouseEvent) => void; onTrackMouseDown?: (event: React.MouseEvent) => void;
onTrackClick?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean; shouldIgnoreClick?: () => boolean;
} }
@@ -46,6 +47,7 @@ export function TimelineTrackContent({
onElementMouseDown, onElementMouseDown,
onElementClick, onElementClick,
onTrackMouseDown, onTrackMouseDown,
onTrackClick,
shouldIgnoreClick, shouldIgnoreClick,
}: TimelineTrackContentProps) { }: TimelineTrackContentProps) {
const editor = useEditor(); const editor = useEditor();
@@ -68,11 +70,13 @@ export function TimelineTrackContent({
return ( return (
<button <button
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")} className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
onClick={() => { onClick={(event) => {
if (shouldIgnoreClick?.()) return; if (shouldIgnoreClick?.()) return;
clearElementSelection(); clearElementSelection();
onTrackClick?.(event);
}} }}
onMouseDown={(event) => { onMouseDown={(event) => {
event.preventDefault();
onTrackMouseDown?.(event); onTrackMouseDown?.(event);
}} }}
type="button" type="button"
@@ -35,12 +35,11 @@ export const TRACK_GAP = 4;
export const TIMELINE_CONSTANTS = { export const TIMELINE_CONSTANTS = {
PIXELS_PER_SECOND: 50, PIXELS_PER_SECOND: 50,
DEFAULT_ELEMENT_DURATION: 5, DEFAULT_ELEMENT_DURATION: 5,
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // padding ahead
PADDING_TOP_PX: 0, 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_MIN: 0.1,
ZOOM_MAX: 100, ZOOM_MAX: 100,
ZOOM_STEP: 0.1, ZOOM_BUTTON_FACTOR: 1.7,
ZOOM_ANCHOR_PLAYHEAD_THRESHOLD: 0.15,
} as const; } as const;
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = { export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
@@ -9,6 +9,7 @@ import {
} from "react"; } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
interface UseTimelineZoomProps { interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement | null>; containerRef: RefObject<HTMLDivElement | null>;
@@ -122,18 +123,47 @@ export function useTimelineZoom({
if (previousZoom === zoomLevel) return; if (previousZoom === zoomLevel) return;
const scrollElement = tracksScrollRef.current; const scrollElement = tracksScrollRef.current;
if (scrollElement) { if (!scrollElement) {
editor.project.setTimelineViewState({ previousZoomRef.current = zoomLevel;
viewState: { return;
zoomLevel, }
scrollLeft: scrollElement.scrollLeft,
playheadTime: editor.playback.getCurrentTime(), 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; 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 // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
const saveScrollPosition = useCallback(() => { const saveScrollPosition = useCallback(() => {
+33
View File
@@ -51,3 +51,36 @@ export function getZoomPercent({
}): number { }): number {
return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom); return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom);
} }
/**
* 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,
minZoom,
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
}: {
sliderPosition: number;
minZoom: number;
maxZoom?: number;
}): number {
const clampedPosition = Math.max(0, Math.min(1, sliderPosition));
return minZoom * (maxZoom / minZoom) ** clampedPosition;
}
/**
* convert exponential zoom level to linear slider position (0-1).
*/
export function zoomToSlider({
zoomLevel,
minZoom,
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
}: {
zoomLevel: number;
minZoom: number;
maxZoom?: number;
}): number {
const clampedZoom = Math.max(minZoom, Math.min(maxZoom, zoomLevel));
return Math.log(clampedZoom / minZoom) / Math.log(maxZoom / minZoom);
}