@hyperframes/studio 0.4.32 → 0.4.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-BV9ymBm4.js +93 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/player/components/Timeline.test.ts +71 -0
- package/src/player/components/Timeline.tsx +146 -9
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/dist/assets/index-DxwbBcYY.js +0 -93
package/dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<title>HyperFrames Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-BV9ymBm4.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-DeztUnf4.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.34",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"codemirror": "^6.0.1",
|
|
34
34
|
"motion": "^12.38.0",
|
|
35
|
-
"@hyperframes/
|
|
36
|
-
"@hyperframes/
|
|
35
|
+
"@hyperframes/player": "0.4.34",
|
|
36
|
+
"@hyperframes/core": "0.4.34"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/react": "^19.0.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"vite": "^6.4.2",
|
|
48
48
|
"vitest": "^3.2.4",
|
|
49
49
|
"zustand": "^5.0.0",
|
|
50
|
-
"@hyperframes/producer": "0.4.
|
|
50
|
+
"@hyperframes/producer": "0.4.34"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
formatTimelineTickLabel,
|
|
3
4
|
generateTicks,
|
|
4
5
|
getDefaultDroppedTrack,
|
|
5
6
|
getTimelineCanvasHeight,
|
|
6
7
|
resolveTimelineAssetDrop,
|
|
7
8
|
getTimelinePlayheadLeft,
|
|
9
|
+
getTimelineScrollLeftForZoomAnchor,
|
|
8
10
|
getTimelineScrollLeftForZoomTransition,
|
|
9
11
|
shouldHandleTimelineDeleteKey,
|
|
10
12
|
shouldAutoScrollTimeline,
|
|
@@ -78,6 +80,20 @@ describe("generateTicks", () => {
|
|
|
78
80
|
expect(major[0]).toBe(0);
|
|
79
81
|
}
|
|
80
82
|
});
|
|
83
|
+
|
|
84
|
+
it("uses denser major labels as timeline zoom increases", () => {
|
|
85
|
+
const fitTicks = generateTicks(180, 10);
|
|
86
|
+
const zoomedTicks = generateTicks(180, 48);
|
|
87
|
+
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
|
|
88
|
+
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
|
|
89
|
+
expect(zoomedTicks.minor).toContain(1);
|
|
90
|
+
expect(zoomedTicks.minor).toContain(4);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("keeps labels readable instead of placing one at every tiny tick", () => {
|
|
94
|
+
const { major } = generateTicks(180, 80);
|
|
95
|
+
expect(major[1] - major[0]).toBe(2);
|
|
96
|
+
});
|
|
81
97
|
});
|
|
82
98
|
|
|
83
99
|
describe("formatTime", () => {
|
|
@@ -118,6 +134,20 @@ describe("formatTime", () => {
|
|
|
118
134
|
});
|
|
119
135
|
});
|
|
120
136
|
|
|
137
|
+
describe("formatTimelineTickLabel", () => {
|
|
138
|
+
it("uses minute-second labels for normal timeline intervals", () => {
|
|
139
|
+
expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("uses hour labels for long timelines", () => {
|
|
143
|
+
expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("shows subsecond labels when the major ruler interval is below one second", () => {
|
|
147
|
+
expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
121
151
|
describe("shouldAutoScrollTimeline", () => {
|
|
122
152
|
it("never auto-scrolls in fit mode", () => {
|
|
123
153
|
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
|
|
@@ -145,6 +175,47 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
|
|
|
145
175
|
});
|
|
146
176
|
});
|
|
147
177
|
|
|
178
|
+
describe("getTimelineScrollLeftForZoomAnchor", () => {
|
|
179
|
+
it("preserves the time under the pointer when zooming in", () => {
|
|
180
|
+
expect(
|
|
181
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
182
|
+
pointerX: 300,
|
|
183
|
+
currentScrollLeft: 200,
|
|
184
|
+
gutter: 32,
|
|
185
|
+
currentPixelsPerSecond: 10,
|
|
186
|
+
nextPixelsPerSecond: 20,
|
|
187
|
+
duration: 120,
|
|
188
|
+
}),
|
|
189
|
+
).toBe(668);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("clamps negative scroll targets", () => {
|
|
193
|
+
expect(
|
|
194
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
195
|
+
pointerX: 300,
|
|
196
|
+
currentScrollLeft: 0,
|
|
197
|
+
gutter: 32,
|
|
198
|
+
currentPixelsPerSecond: 20,
|
|
199
|
+
nextPixelsPerSecond: 5,
|
|
200
|
+
duration: 120,
|
|
201
|
+
}),
|
|
202
|
+
).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("preserves current scroll when inputs are invalid", () => {
|
|
206
|
+
expect(
|
|
207
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
208
|
+
pointerX: 300,
|
|
209
|
+
currentScrollLeft: 120,
|
|
210
|
+
gutter: 32,
|
|
211
|
+
currentPixelsPerSecond: 0,
|
|
212
|
+
nextPixelsPerSecond: 20,
|
|
213
|
+
duration: 120,
|
|
214
|
+
}),
|
|
215
|
+
).toBe(120);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
148
219
|
describe("getTimelinePlayheadLeft", () => {
|
|
149
220
|
it("converts time to a pixel offset from the gutter", () => {
|
|
150
221
|
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type TimelineTrackStyle,
|
|
27
27
|
type TimelineTheme,
|
|
28
28
|
} from "./timelineTheme";
|
|
29
|
-
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
29
|
+
import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
30
30
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
31
31
|
|
|
32
32
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
|
@@ -88,16 +88,47 @@ function getStyle(tag: string): TrackVisualStyle {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/* ── Tick Generation ────────────────────────────────────────────── */
|
|
91
|
-
|
|
91
|
+
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
|
|
92
|
+
const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
|
93
|
+
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
|
94
|
+
const targetMajorPx = 128;
|
|
95
|
+
return (
|
|
96
|
+
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
|
100
|
+
const target = duration / 6;
|
|
101
|
+
return durationIntervals.find((interval) => interval >= target) ?? 60;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
|
|
105
|
+
let interval = majorInterval / 2;
|
|
106
|
+
if (majorInterval >= 30) interval = majorInterval / 6;
|
|
107
|
+
else if (majorInterval >= 15) interval = majorInterval / 3;
|
|
108
|
+
else if (majorInterval >= 5) interval = majorInterval / 5;
|
|
109
|
+
else if (majorInterval >= 1) interval = majorInterval / 4;
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
Number.isFinite(pixelsPerSecond) &&
|
|
113
|
+
(pixelsPerSecond ?? 0) > 0 &&
|
|
114
|
+
interval * (pixelsPerSecond ?? 0) < 20
|
|
115
|
+
) {
|
|
116
|
+
return Math.max(0.25, majorInterval / 2);
|
|
117
|
+
}
|
|
118
|
+
return Math.max(0.25, interval);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function generateTicks(
|
|
122
|
+
duration: number,
|
|
123
|
+
pixelsPerSecond?: number,
|
|
124
|
+
): { major: number[]; minor: number[] } {
|
|
92
125
|
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
|
|
93
126
|
return { major: [], minor: [] };
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const majorInterval = intervals.find((i) => i >= target) ?? 60;
|
|
97
|
-
const minorInterval = Math.max(0.25, majorInterval / 2);
|
|
127
|
+
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
|
|
128
|
+
const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
|
|
98
129
|
const major: number[] = [];
|
|
99
130
|
const minor: number[] = [];
|
|
100
|
-
const maxTicks =
|
|
131
|
+
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
|
101
132
|
for (
|
|
102
133
|
let t = 0;
|
|
103
134
|
t <= duration + 0.001 && major.length + minor.length < maxTicks;
|
|
@@ -113,6 +144,25 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
|
|
|
113
144
|
return { major, minor };
|
|
114
145
|
}
|
|
115
146
|
|
|
147
|
+
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
|
148
|
+
if (!Number.isFinite(time)) return "0:00";
|
|
149
|
+
const safeTime = Math.max(0, time);
|
|
150
|
+
if (majorInterval < 1) {
|
|
151
|
+
const totalTenths = Math.round(safeTime * 10);
|
|
152
|
+
const wholeSeconds = Math.floor(totalTenths / 10);
|
|
153
|
+
const tenth = totalTenths % 10;
|
|
154
|
+
return `${formatTime(wholeSeconds)}.${tenth}`;
|
|
155
|
+
}
|
|
156
|
+
if (duration >= 3600 || safeTime >= 3600) {
|
|
157
|
+
const totalSeconds = Math.floor(safeTime);
|
|
158
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
159
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
160
|
+
const seconds = totalSeconds % 60;
|
|
161
|
+
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
162
|
+
}
|
|
163
|
+
return formatTime(safeTime);
|
|
164
|
+
}
|
|
165
|
+
|
|
116
166
|
export function shouldAutoScrollTimeline(
|
|
117
167
|
zoomMode: ZoomMode,
|
|
118
168
|
scrollWidth: number,
|
|
@@ -132,6 +182,31 @@ export function getTimelineScrollLeftForZoomTransition(
|
|
|
132
182
|
return currentScrollLeft;
|
|
133
183
|
}
|
|
134
184
|
|
|
185
|
+
export function getTimelineScrollLeftForZoomAnchor(input: {
|
|
186
|
+
pointerX: number;
|
|
187
|
+
currentScrollLeft: number;
|
|
188
|
+
gutter: number;
|
|
189
|
+
currentPixelsPerSecond: number;
|
|
190
|
+
nextPixelsPerSecond: number;
|
|
191
|
+
duration: number;
|
|
192
|
+
}): number {
|
|
193
|
+
const currentPps = Math.max(0, input.currentPixelsPerSecond);
|
|
194
|
+
const nextPps = Math.max(0, input.nextPixelsPerSecond);
|
|
195
|
+
if (
|
|
196
|
+
!Number.isFinite(input.pointerX) ||
|
|
197
|
+
!Number.isFinite(input.currentScrollLeft) ||
|
|
198
|
+
!Number.isFinite(input.duration) ||
|
|
199
|
+
input.duration <= 0 ||
|
|
200
|
+
currentPps <= 0 ||
|
|
201
|
+
nextPps <= 0
|
|
202
|
+
) {
|
|
203
|
+
return Math.max(0, input.currentScrollLeft);
|
|
204
|
+
}
|
|
205
|
+
const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
|
|
206
|
+
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
|
|
207
|
+
return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
|
|
208
|
+
}
|
|
209
|
+
|
|
135
210
|
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
|
136
211
|
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
|
|
137
212
|
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
|
|
@@ -306,6 +381,8 @@ export const Timeline = memo(function Timeline({
|
|
|
306
381
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
307
382
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
308
383
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
384
|
+
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
385
|
+
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
309
386
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
310
387
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
311
388
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
@@ -435,7 +512,11 @@ export const Timeline = memo(function Timeline({
|
|
|
435
512
|
const trackContentWidth = Math.max(0, effectiveDuration * pps);
|
|
436
513
|
const zoomModeRef = useRef(zoomMode);
|
|
437
514
|
zoomModeRef.current = zoomMode;
|
|
515
|
+
const manualZoomPercentRef = useRef(manualZoomPercent);
|
|
516
|
+
manualZoomPercentRef.current = manualZoomPercent;
|
|
438
517
|
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
|
518
|
+
const fitPpsRef = useRef(fitPps);
|
|
519
|
+
fitPpsRef.current = fitPps;
|
|
439
520
|
|
|
440
521
|
const durationRef = useRef(effectiveDuration);
|
|
441
522
|
durationRef.current = effectiveDuration;
|
|
@@ -925,7 +1006,12 @@ export const Timeline = memo(function Timeline({
|
|
|
925
1006
|
cancelAnimationFrame(dragScrollRaf.current);
|
|
926
1007
|
}, []);
|
|
927
1008
|
|
|
928
|
-
const { major, minor } = useMemo(
|
|
1009
|
+
const { major, minor } = useMemo(
|
|
1010
|
+
() => generateTicks(effectiveDuration, pps),
|
|
1011
|
+
[effectiveDuration, pps],
|
|
1012
|
+
);
|
|
1013
|
+
const majorTickInterval =
|
|
1014
|
+
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
|
|
929
1015
|
const getPreviewElement = useCallback(
|
|
930
1016
|
(element: TimelineElement): TimelineElement => {
|
|
931
1017
|
if (resizingClip?.element.id === element.id) {
|
|
@@ -1011,6 +1097,57 @@ export const Timeline = memo(function Timeline({
|
|
|
1011
1097
|
[onAssetDrop, onFileDrop],
|
|
1012
1098
|
);
|
|
1013
1099
|
|
|
1100
|
+
const handlePinchWheel = useCallback(
|
|
1101
|
+
(e: WheelEvent) => {
|
|
1102
|
+
if (!e.ctrlKey) return;
|
|
1103
|
+
const scroll = scrollRef.current;
|
|
1104
|
+
if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) {
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
e.preventDefault();
|
|
1109
|
+
e.stopPropagation();
|
|
1110
|
+
|
|
1111
|
+
const rect = scroll.getBoundingClientRect();
|
|
1112
|
+
const pointerX = e.clientX - rect.left;
|
|
1113
|
+
const nextZoomPercent = getPinchTimelineZoomPercent(
|
|
1114
|
+
e.deltaY,
|
|
1115
|
+
zoomModeRef.current,
|
|
1116
|
+
manualZoomPercentRef.current,
|
|
1117
|
+
);
|
|
1118
|
+
if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual") {
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
|
|
1123
|
+
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
1124
|
+
pointerX,
|
|
1125
|
+
currentScrollLeft: scroll.scrollLeft,
|
|
1126
|
+
gutter: GUTTER,
|
|
1127
|
+
currentPixelsPerSecond: ppsRef.current,
|
|
1128
|
+
nextPixelsPerSecond: nextPps,
|
|
1129
|
+
duration: durationRef.current,
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
setZoomMode("manual");
|
|
1133
|
+
setManualZoomPercent(nextZoomPercent);
|
|
1134
|
+
requestAnimationFrame(() => {
|
|
1135
|
+
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
1136
|
+
scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
|
|
1137
|
+
});
|
|
1138
|
+
},
|
|
1139
|
+
[setManualZoomPercent, setZoomMode],
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
useEffect(() => {
|
|
1143
|
+
const scroll = scrollRef.current;
|
|
1144
|
+
if (!scroll) return;
|
|
1145
|
+
scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
|
|
1146
|
+
return () => {
|
|
1147
|
+
scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
|
|
1148
|
+
};
|
|
1149
|
+
}, [handlePinchWheel, timelineReady, elements.length]);
|
|
1150
|
+
|
|
1014
1151
|
if (!timelineReady || elements.length === 0) {
|
|
1015
1152
|
return (
|
|
1016
1153
|
<div
|
|
@@ -1242,7 +1379,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1242
1379
|
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
|
|
1243
1380
|
style={{ color: theme.tickText }}
|
|
1244
1381
|
>
|
|
1245
|
-
{
|
|
1382
|
+
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
|
|
1246
1383
|
</span>
|
|
1247
1384
|
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
|
|
1248
1385
|
</div>
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
clampTimelineZoomPercent,
|
|
4
4
|
getNextTimelineZoomPercent,
|
|
5
|
+
getPinchTimelineZoomPercent,
|
|
5
6
|
getTimelinePixelsPerSecond,
|
|
6
7
|
getTimelineZoomPercent,
|
|
7
8
|
MAX_TIMELINE_ZOOM_PERCENT,
|
|
@@ -60,3 +61,23 @@ describe("getNextTimelineZoomPercent", () => {
|
|
|
60
61
|
);
|
|
61
62
|
});
|
|
62
63
|
});
|
|
64
|
+
|
|
65
|
+
describe("getPinchTimelineZoomPercent", () => {
|
|
66
|
+
it("zooms in for upward pinch wheel deltas", () => {
|
|
67
|
+
expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("zooms out for downward pinch wheel deltas", () => {
|
|
71
|
+
expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps the current zoom for zero or invalid deltas", () => {
|
|
75
|
+
expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180);
|
|
76
|
+
expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("clamps pinch zoom to the supported range", () => {
|
|
80
|
+
expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
81
|
+
expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -4,6 +4,7 @@ export const MIN_TIMELINE_ZOOM_PERCENT = 10;
|
|
|
4
4
|
export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
|
|
5
5
|
const ZOOM_OUT_FACTOR = 0.8;
|
|
6
6
|
const ZOOM_IN_FACTOR = 1.25;
|
|
7
|
+
const PINCH_ZOOM_SENSITIVITY = 0.0035;
|
|
7
8
|
|
|
8
9
|
export function clampTimelineZoomPercent(percent: number): number {
|
|
9
10
|
if (!Number.isFinite(percent)) return 100;
|
|
@@ -36,3 +37,13 @@ export function getNextTimelineZoomPercent(
|
|
|
36
37
|
const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
|
|
37
38
|
return clampTimelineZoomPercent(next);
|
|
38
39
|
}
|
|
40
|
+
|
|
41
|
+
export function getPinchTimelineZoomPercent(
|
|
42
|
+
deltaY: number,
|
|
43
|
+
zoomMode: ZoomMode,
|
|
44
|
+
manualZoomPercent: number,
|
|
45
|
+
): number {
|
|
46
|
+
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
47
|
+
if (!Number.isFinite(deltaY) || deltaY === 0) return current;
|
|
48
|
+
return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
|
|
49
|
+
}
|