mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): support trackpad timeline pinch zoom
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user