From 820b07ddaafd514f2cc3ff95d6c78b0496525b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 28 Apr 2026 17:28:04 -0400 Subject: [PATCH] feat(studio): support trackpad timeline pinch zoom --- .../src/player/components/Timeline.test.ts | 42 ++++++++++ .../studio/src/player/components/Timeline.tsx | 76 ++++++++++++++++++- .../player/components/timelineZoom.test.ts | 21 +++++ .../src/player/components/timelineZoom.ts | 11 +++ 4 files changed, 149 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 3151f4a0b..940e144a4 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -5,6 +5,7 @@ import { getTimelineCanvasHeight, resolveTimelineAssetDrop, getTimelinePlayheadLeft, + getTimelineScrollLeftForZoomAnchor, getTimelineScrollLeftForZoomTransition, shouldHandleTimelineDeleteKey, shouldAutoScrollTimeline, @@ -145,6 +146,47 @@ describe("getTimelineScrollLeftForZoomTransition", () => { }); }); +describe("getTimelineScrollLeftForZoomAnchor", () => { + it("preserves the time under the pointer when zooming in", () => { + expect( + getTimelineScrollLeftForZoomAnchor({ + pointerX: 300, + currentScrollLeft: 200, + gutter: 32, + currentPixelsPerSecond: 10, + nextPixelsPerSecond: 20, + duration: 120, + }), + ).toBe(668); + }); + + it("clamps negative scroll targets", () => { + expect( + getTimelineScrollLeftForZoomAnchor({ + pointerX: 300, + currentScrollLeft: 0, + gutter: 32, + currentPixelsPerSecond: 20, + nextPixelsPerSecond: 5, + duration: 120, + }), + ).toBe(0); + }); + + it("preserves current scroll when inputs are invalid", () => { + expect( + getTimelineScrollLeftForZoomAnchor({ + pointerX: 300, + currentScrollLeft: 120, + gutter: 32, + currentPixelsPerSecond: 0, + nextPixelsPerSecond: 20, + duration: 120, + }), + ).toBe(120); + }); +}); + describe("getTimelinePlayheadLeft", () => { it("converts time to a pixel offset from the gutter", () => { expect(getTimelinePlayheadLeft(4, 20)).toBe(112); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 7dba1e138..d4b75f59f 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -26,7 +26,7 @@ import { type TimelineTrackStyle, type TimelineTheme, } from "./timelineTheme"; -import { getTimelinePixelsPerSecond } from "./timelineZoom"; +import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom"; import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; /* ── Layout ─────────────────────────────────────────────────────── */ @@ -132,6 +132,31 @@ export function getTimelineScrollLeftForZoomTransition( return currentScrollLeft; } +export function getTimelineScrollLeftForZoomAnchor(input: { + pointerX: number; + currentScrollLeft: number; + gutter: number; + currentPixelsPerSecond: number; + nextPixelsPerSecond: number; + duration: number; +}): number { + const currentPps = Math.max(0, input.currentPixelsPerSecond); + const nextPps = Math.max(0, input.nextPixelsPerSecond); + if ( + !Number.isFinite(input.pointerX) || + !Number.isFinite(input.currentScrollLeft) || + !Number.isFinite(input.duration) || + input.duration <= 0 || + currentPps <= 0 || + nextPps <= 0 + ) { + return Math.max(0, input.currentScrollLeft); + } + const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter); + const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps)); + return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX); +} + export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number { if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER; return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond); @@ -306,6 +331,8 @@ export const Timeline = memo(function Timeline({ const currentTime = usePlayerStore((s) => s.currentTime); const zoomMode = usePlayerStore((s) => s.zoomMode); const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); + const setZoomMode = usePlayerStore((s) => s.setZoomMode); + const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent); const playheadRef = useRef(null); const containerRef = useRef(null); const scrollRef = useRef(null); @@ -435,7 +462,11 @@ export const Timeline = memo(function Timeline({ const trackContentWidth = Math.max(0, effectiveDuration * pps); const zoomModeRef = useRef(zoomMode); zoomModeRef.current = zoomMode; + const manualZoomPercentRef = useRef(manualZoomPercent); + manualZoomPercentRef.current = manualZoomPercent; const previousZoomModeRef = useRef(zoomMode); + const fitPpsRef = useRef(fitPps); + fitPpsRef.current = fitPps; const durationRef = useRef(effectiveDuration); durationRef.current = effectiveDuration; @@ -1011,6 +1042,48 @@ export const Timeline = memo(function Timeline({ [onAssetDrop, onFileDrop], ); + const handleWheel = useCallback( + (e: React.WheelEvent) => { + if (!e.ctrlKey) return; + const scroll = scrollRef.current; + if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const rect = scroll.getBoundingClientRect(); + const pointerX = e.clientX - rect.left; + const nextZoomPercent = getPinchTimelineZoomPercent( + e.deltaY, + zoomModeRef.current, + manualZoomPercentRef.current, + ); + if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual") { + return; + } + + const nextPps = fitPpsRef.current * (nextZoomPercent / 100); + const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({ + pointerX, + currentScrollLeft: scroll.scrollLeft, + gutter: GUTTER, + currentPixelsPerSecond: ppsRef.current, + nextPixelsPerSecond: nextPps, + duration: durationRef.current, + }); + + setZoomMode("manual"); + setManualZoomPercent(nextZoomPercent); + requestAnimationFrame(() => { + const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth); + scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft); + }); + }, + [setManualZoomPercent, setZoomMode], + ); + if (!timelineReady || elements.length === 0) { return (
setIsDragOver(false)} onDrop={handleAssetDrop} + onWheel={handleWheel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} diff --git a/packages/studio/src/player/components/timelineZoom.test.ts b/packages/studio/src/player/components/timelineZoom.test.ts index bcd961639..3e4a35cac 100644 --- a/packages/studio/src/player/components/timelineZoom.test.ts +++ b/packages/studio/src/player/components/timelineZoom.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { clampTimelineZoomPercent, getNextTimelineZoomPercent, + getPinchTimelineZoomPercent, getTimelinePixelsPerSecond, getTimelineZoomPercent, MAX_TIMELINE_ZOOM_PERCENT, @@ -60,3 +61,23 @@ describe("getNextTimelineZoomPercent", () => { ); }); }); + +describe("getPinchTimelineZoomPercent", () => { + it("zooms in for upward pinch wheel deltas", () => { + expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100); + }); + + it("zooms out for downward pinch wheel deltas", () => { + expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200); + }); + + it("keeps the current zoom for zero or invalid deltas", () => { + expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180); + expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180); + }); + + it("clamps pinch zoom to the supported range", () => { + expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(MIN_TIMELINE_ZOOM_PERCENT); + expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT); + }); +}); diff --git a/packages/studio/src/player/components/timelineZoom.ts b/packages/studio/src/player/components/timelineZoom.ts index abc15ca2b..0ac4a92b2 100644 --- a/packages/studio/src/player/components/timelineZoom.ts +++ b/packages/studio/src/player/components/timelineZoom.ts @@ -4,6 +4,7 @@ export const MIN_TIMELINE_ZOOM_PERCENT = 10; export const MAX_TIMELINE_ZOOM_PERCENT = 2000; const ZOOM_OUT_FACTOR = 0.8; const ZOOM_IN_FACTOR = 1.25; +const PINCH_ZOOM_SENSITIVITY = 0.0035; export function clampTimelineZoomPercent(percent: number): number { if (!Number.isFinite(percent)) return 100; @@ -36,3 +37,13 @@ export function getNextTimelineZoomPercent( const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR; return clampTimelineZoomPercent(next); } + +export function getPinchTimelineZoomPercent( + deltaY: number, + zoomMode: ZoomMode, + manualZoomPercent: number, +): number { + const current = getTimelineZoomPercent(zoomMode, manualZoomPercent); + if (!Number.isFinite(deltaY) || deltaY === 0) return current; + return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY)); +}