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,
|
getTimelineCanvasHeight,
|
||||||
resolveTimelineAssetDrop,
|
resolveTimelineAssetDrop,
|
||||||
getTimelinePlayheadLeft,
|
getTimelinePlayheadLeft,
|
||||||
|
getTimelineScrollLeftForZoomAnchor,
|
||||||
getTimelineScrollLeftForZoomTransition,
|
getTimelineScrollLeftForZoomTransition,
|
||||||
shouldHandleTimelineDeleteKey,
|
shouldHandleTimelineDeleteKey,
|
||||||
shouldAutoScrollTimeline,
|
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", () => {
|
describe("getTimelinePlayheadLeft", () => {
|
||||||
it("converts time to a pixel offset from the gutter", () => {
|
it("converts time to a pixel offset from the gutter", () => {
|
||||||
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
|
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
type TimelineTrackStyle,
|
type TimelineTrackStyle,
|
||||||
type TimelineTheme,
|
type TimelineTheme,
|
||||||
} from "./timelineTheme";
|
} from "./timelineTheme";
|
||||||
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
|
||||||
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
||||||
|
|
||||||
/* ── Layout ─────────────────────────────────────────────────────── */
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||||
@@ -132,6 +132,31 @@ export function getTimelineScrollLeftForZoomTransition(
|
|||||||
return currentScrollLeft;
|
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 {
|
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
||||||
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
|
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
|
||||||
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
|
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 currentTime = usePlayerStore((s) => s.currentTime);
|
||||||
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
||||||
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
||||||
|
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
||||||
|
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
||||||
const playheadRef = useRef<HTMLDivElement>(null);
|
const playheadRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = 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 trackContentWidth = Math.max(0, effectiveDuration * pps);
|
||||||
const zoomModeRef = useRef(zoomMode);
|
const zoomModeRef = useRef(zoomMode);
|
||||||
zoomModeRef.current = zoomMode;
|
zoomModeRef.current = zoomMode;
|
||||||
|
const manualZoomPercentRef = useRef(manualZoomPercent);
|
||||||
|
manualZoomPercentRef.current = manualZoomPercent;
|
||||||
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
||||||
|
const fitPpsRef = useRef(fitPps);
|
||||||
|
fitPpsRef.current = fitPps;
|
||||||
|
|
||||||
const durationRef = useRef(effectiveDuration);
|
const durationRef = useRef(effectiveDuration);
|
||||||
durationRef.current = effectiveDuration;
|
durationRef.current = effectiveDuration;
|
||||||
@@ -1011,6 +1042,48 @@ export const Timeline = memo(function Timeline({
|
|||||||
[onAssetDrop, onFileDrop],
|
[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) {
|
if (!timelineReady || elements.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1186,6 +1259,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
onDragOver={handleAssetDragOver}
|
onDragOver={handleAssetDragOver}
|
||||||
onDragLeave={() => setIsDragOver(false)}
|
onDragLeave={() => setIsDragOver(false)}
|
||||||
onDrop={handleAssetDrop}
|
onDrop={handleAssetDrop}
|
||||||
|
onWheel={handleWheel}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
clampTimelineZoomPercent,
|
clampTimelineZoomPercent,
|
||||||
getNextTimelineZoomPercent,
|
getNextTimelineZoomPercent,
|
||||||
|
getPinchTimelineZoomPercent,
|
||||||
getTimelinePixelsPerSecond,
|
getTimelinePixelsPerSecond,
|
||||||
getTimelineZoomPercent,
|
getTimelineZoomPercent,
|
||||||
MAX_TIMELINE_ZOOM_PERCENT,
|
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;
|
export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
|
||||||
const ZOOM_OUT_FACTOR = 0.8;
|
const ZOOM_OUT_FACTOR = 0.8;
|
||||||
const ZOOM_IN_FACTOR = 1.25;
|
const ZOOM_IN_FACTOR = 1.25;
|
||||||
|
const PINCH_ZOOM_SENSITIVITY = 0.0035;
|
||||||
|
|
||||||
export function clampTimelineZoomPercent(percent: number): number {
|
export function clampTimelineZoomPercent(percent: number): number {
|
||||||
if (!Number.isFinite(percent)) return 100;
|
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;
|
const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
|
||||||
return clampTimelineZoomPercent(next);
|
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