Merge branch 'timeline-zoom'
This commit is contained in:
commit
ad794fa43b
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -54,9 +55,9 @@ export function TimelineToolbar({
|
|||
direction === "in"
|
||||
? Math.min(
|
||||
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 });
|
||||
};
|
||||
|
||||
|
|
@ -127,17 +128,13 @@ function ToolbarLeftSection() {
|
|||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||
tooltip="Split element"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split", event })
|
||||
}
|
||||
onClick={({ event }) => handleAction({ action: "split", event })}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
|
||||
tooltip="Split left"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split-left", event })
|
||||
}
|
||||
onClick={({ event }) => handleAction({ action: "split-left", event })}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
|
|
@ -302,15 +299,17 @@ function ToolbarRightSection({
|
|||
type="button"
|
||||
onClick={() => onZoom({ direction: "out" })}
|
||||
>
|
||||
<HugeiconsIcon icon={SearchAddIcon} />
|
||||
<HugeiconsIcon icon={SearchMinusIcon} />
|
||||
</Button>
|
||||
<Slider
|
||||
className="w-24"
|
||||
value={[zoomLevel]}
|
||||
onValueChange={(values) => 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}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
|
|
@ -318,7 +317,7 @@ function ToolbarRightSection({
|
|||
type="button"
|
||||
onClick={() => onZoom({ direction: "in" })}
|
||||
>
|
||||
<HugeiconsIcon icon={SearchMinusIcon} />
|
||||
<HugeiconsIcon icon={SearchAddIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<button
|
||||
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
if (shouldIgnoreClick?.()) return;
|
||||
clearElementSelection();
|
||||
onTrackClick?.(event);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onTrackMouseDown?.(event);
|
||||
}}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -35,12 +35,11 @@ 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,
|
||||
ZOOM_ANCHOR_PLAYHEAD_THRESHOLD: 0.15,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>;
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -51,3 +51,36 @@ export function getZoomPercent({
|
|||
}): number {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue