diff options
Diffstat (limited to 'apps/web')
6 files changed, 99 insertions, 30 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} /> </div> diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index 822bb438..6ac6fba5 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"; @@ -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> 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 ( <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" diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx index 692ccd65..d6af3c3a 100644 --- a/apps/web/src/constants/timeline-constants.tsx +++ b/apps/web/src/constants/timeline-constants.tsx @@ -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 = { 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<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(() => { diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts index 49f7fcb6..bbc0b6ae 100644 --- a/apps/web/src/lib/timeline/zoom-utils.ts +++ b/apps/web/src/lib/timeline/zoom-utils.ts @@ -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); +} |
