feat(studio): support trackpad timeline pinch zoom

This commit is contained in:
Miguel Ángel
2026-04-28 17:28:04 -04:00
parent 78d949b844
commit 820b07ddaa
4 changed files with 149 additions and 1 deletions
@@ -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);
@@ -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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(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 | null>(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<HTMLDivElement>) => {
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 (
<div
@@ -1186,6 +1259,7 @@ export const Timeline = memo(function Timeline({
onDragOver={handleAssetDragOver}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleAssetDrop}
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
@@ -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);
});
});
@@ -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));
}