@hyperframes/studio 0.7.18 → 0.7.20
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/hyperframes-player-DNLS_l47.js +459 -0
- package/dist/assets/{index-mZiDOLTB.js → index-CUt5jJXy.js} +1 -1
- package/dist/assets/{index-B7_M3NXS.js → index-D-KLiCTU.js} +1 -1
- package/dist/assets/index-WxXW8fW3.js +396 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.html +1 -1
- package/dist/index.js +1246 -807
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/StudioPreviewArea.tsx +92 -17
- package/src/components/TimelineToolbar.tsx +8 -0
- package/src/components/editor/MotionPathOverlay.tsx +4 -2
- package/src/components/editor/keyframeDrag.test.ts +195 -0
- package/src/components/editor/keyframeDrag.ts +105 -0
- package/src/components/editor/keyframeRetime.test.ts +140 -0
- package/src/components/editor/keyframeRetime.ts +121 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/gsapDragCommit.ts +28 -1
- package/src/hooks/gsapPositionDetection.ts +98 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +33 -0
- package/src/hooks/gsapRuntimeBridge.ts +41 -97
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditWiring.ts +17 -0
- package/src/hooks/useGsapKeyframeOps.test.tsx +101 -0
- package/src/hooks/useGsapKeyframeOps.ts +63 -2
- package/src/hooks/useGsapSelectionHandlers.ts +72 -1
- package/src/hooks/useGsapTweenCache.ts +16 -7
- package/src/hooks/useKeyframeKeyboard.ts +29 -32
- package/src/player/components/KeyframeDiamondContextMenu.tsx +21 -2
- package/src/player/components/PlayerControls.tsx +40 -13
- package/src/player/components/Timeline.tsx +8 -0
- package/src/player/components/TimelineCanvas.tsx +7 -0
- package/src/player/components/TimelineClipDiamonds.tsx +109 -17
- package/src/player/components/timelineCallbacks.ts +6 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +19 -0
- package/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
- package/dist/assets/index-D0468l1X.js +0 -375
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.20",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,13 +43,14 @@
|
|
|
43
43
|
"@phosphor-icons/react": "^2.1.10",
|
|
44
44
|
"bpm-detective": "^2.0.5",
|
|
45
45
|
"dompurify": "^3.2.4",
|
|
46
|
+
"gsap": "^3.13.0",
|
|
46
47
|
"marked": "^14.1.4",
|
|
47
48
|
"mediabunny": "^1.45.3",
|
|
48
|
-
"@hyperframes/
|
|
49
|
-
"@hyperframes/
|
|
50
|
-
"@hyperframes/sdk": "0.7.
|
|
51
|
-
"@hyperframes/studio-server": "0.7.
|
|
52
|
-
"@hyperframes/core": "0.7.
|
|
49
|
+
"@hyperframes/player": "0.7.20",
|
|
50
|
+
"@hyperframes/parsers": "0.7.20",
|
|
51
|
+
"@hyperframes/sdk": "0.7.20",
|
|
52
|
+
"@hyperframes/studio-server": "0.7.20",
|
|
53
|
+
"@hyperframes/core": "0.7.20"
|
|
53
54
|
},
|
|
54
55
|
"devDependencies": {
|
|
55
56
|
"@types/react": "19",
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
"vite": "^6.4.2",
|
|
65
66
|
"vitest": "^3.2.4",
|
|
66
67
|
"zustand": "^5.0.0",
|
|
67
|
-
"@hyperframes/producer": "0.7.
|
|
68
|
+
"@hyperframes/producer": "0.7.20"
|
|
68
69
|
},
|
|
69
70
|
"peerDependencies": {
|
|
70
71
|
"react": "19",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo, type ReactNode } from "react";
|
|
1
|
+
import { useState, useMemo, useCallback, type ReactNode } from "react";
|
|
2
2
|
import { NLELayout } from "./nle/NLELayout";
|
|
3
3
|
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
|
|
4
4
|
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
|
|
@@ -18,6 +18,9 @@ import {
|
|
|
18
18
|
} from "./editor/manualEditingAvailability";
|
|
19
19
|
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
|
|
20
20
|
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
|
|
21
|
+
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
22
|
+
import { resolveClipTimingBasis } from "../hooks/useGsapTweenCache";
|
|
23
|
+
import { resolveKeyframeRetime } from "./editor/keyframeRetime";
|
|
21
24
|
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
|
|
22
25
|
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
|
|
23
26
|
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
@@ -130,10 +133,13 @@ export function StudioPreviewArea({
|
|
|
130
133
|
handleDomBoxSizeCommit,
|
|
131
134
|
handleDomRotationCommit,
|
|
132
135
|
handleGsapRemoveKeyframe,
|
|
136
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
137
|
+
handleGsapMoveKeyframe,
|
|
138
|
+
handleGsapResizeKeyframedTween,
|
|
133
139
|
handleGsapUpdateMeta,
|
|
134
140
|
handleGsapAddKeyframe,
|
|
135
141
|
handleGsapConvertToKeyframes,
|
|
136
|
-
|
|
142
|
+
handleGsapRemoveAllKeyframes,
|
|
137
143
|
buildDomSelectionForTimelineElement,
|
|
138
144
|
applyMarqueeSelection,
|
|
139
145
|
} = useDomEditActionsContext();
|
|
@@ -149,6 +155,23 @@ export function StudioPreviewArea({
|
|
|
149
155
|
};
|
|
150
156
|
});
|
|
151
157
|
|
|
158
|
+
// Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its
|
|
159
|
+
// tween-relative percentage (shared by the delete/move keyframe callbacks): the
|
|
160
|
+
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
|
|
161
|
+
// anim in the keyframe's property group, falling back to the first keyframed one.
|
|
162
|
+
const resolveKeyframeTarget = useCallback(
|
|
163
|
+
(pct: number): { animId: string; tweenPct: number } | null => {
|
|
164
|
+
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
|
|
165
|
+
const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
|
166
|
+
const group = kf?.propertyGroup;
|
|
167
|
+
const anim =
|
|
168
|
+
(group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
|
|
169
|
+
selectedGsapAnimations.find((a) => a.keyframes);
|
|
170
|
+
return anim ? { animId: anim.id, tweenPct: kf?.tweenPercentage ?? pct } : null;
|
|
171
|
+
},
|
|
172
|
+
[domEditSelection?.id, selectedGsapAnimations],
|
|
173
|
+
);
|
|
174
|
+
|
|
152
175
|
// fallow-ignore-next-line complexity
|
|
153
176
|
const timelineEditCallbacks = useMemo(
|
|
154
177
|
() => ({
|
|
@@ -158,21 +181,69 @@ export function StudioPreviewArea({
|
|
|
158
181
|
onSplitElement: handleTimelineElementSplit,
|
|
159
182
|
onRazorSplit: handleRazorSplit,
|
|
160
183
|
onRazorSplitAll: handleRazorSplitAll,
|
|
161
|
-
onDeleteAllKeyframes: (
|
|
162
|
-
|
|
163
|
-
|
|
184
|
+
onDeleteAllKeyframes: () => {
|
|
185
|
+
// Hold the element where it is (collapse keyframes to a static set) rather
|
|
186
|
+
// than deleting the whole animation — deleting strands a stale GSAP base
|
|
187
|
+
// that the next drag adds to, flinging the element off-screen.
|
|
188
|
+
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
|
189
|
+
if (!anim) return;
|
|
190
|
+
handleGsapRemoveAllKeyframes(anim.id);
|
|
164
191
|
},
|
|
165
|
-
// fallow-ignore-next-line complexity
|
|
166
192
|
onDeleteKeyframe: (_elId: string, pct: number) => {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
193
|
+
const target = resolveKeyframeTarget(pct);
|
|
194
|
+
if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct);
|
|
195
|
+
},
|
|
196
|
+
// Retime the keyframe to the playhead, preserving its value + ease.
|
|
197
|
+
onMoveKeyframeToPlayhead: (_elId: string, pct: number) => {
|
|
198
|
+
const target = resolveKeyframeTarget(pct);
|
|
199
|
+
if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct);
|
|
200
|
+
},
|
|
201
|
+
// Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives
|
|
202
|
+
// the dragged keyframe's anim + tween-%. We convert the clip-% drop to an
|
|
203
|
+
// absolute time (via the clip's timing basis) and let resolveKeyframeRetime
|
|
204
|
+
// decide: a drop inside the tween window is a plain move (re-key tween-%); a
|
|
205
|
+
// drop past the boundary (last keyframe past the end, first before the start)
|
|
206
|
+
// resizes the tween — position/duration grow so the dragged keyframe lands at
|
|
207
|
+
// the drop while every other keyframe keeps its absolute time (value+ease too).
|
|
208
|
+
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
|
|
209
|
+
const target = resolveKeyframeTarget(fromClipPct);
|
|
210
|
+
const sel = domEditSelection;
|
|
211
|
+
if (!target || !sel) return;
|
|
212
|
+
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
|
|
213
|
+
const tweenStart = anim ? resolveTweenStart(anim) : null;
|
|
214
|
+
if (!anim || tweenStart === null) return;
|
|
215
|
+
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
|
|
216
|
+
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
|
|
217
|
+
const { elements, domClipChildren } = usePlayerStore.getState();
|
|
218
|
+
const { elStart, elDuration } = resolveClipTimingBasis(
|
|
219
|
+
sel.id ?? "",
|
|
220
|
+
sourceFile,
|
|
221
|
+
elements,
|
|
222
|
+
domClipChildren,
|
|
223
|
+
);
|
|
224
|
+
const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
|
|
225
|
+
const decision = resolveKeyframeRetime({
|
|
226
|
+
keyframes: anim.keyframes?.keyframes ?? [],
|
|
227
|
+
draggedTweenPct: target.tweenPct,
|
|
228
|
+
tweenStart,
|
|
229
|
+
tweenDuration,
|
|
230
|
+
dropAbsTime,
|
|
231
|
+
});
|
|
232
|
+
if (decision.kind === "move" && decision.toTweenPct != null) {
|
|
233
|
+
handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct);
|
|
234
|
+
} else if (
|
|
235
|
+
decision.kind === "resize" &&
|
|
236
|
+
decision.pctRemap &&
|
|
237
|
+
decision.position != null &&
|
|
238
|
+
decision.duration != null
|
|
239
|
+
) {
|
|
240
|
+
handleGsapResizeKeyframedTween(
|
|
241
|
+
target.animId,
|
|
242
|
+
decision.position,
|
|
243
|
+
decision.duration,
|
|
244
|
+
decision.pctRemap,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
176
247
|
},
|
|
177
248
|
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
|
|
178
249
|
for (const anim of selectedGsapAnimations) {
|
|
@@ -208,16 +279,20 @@ export function StudioPreviewArea({
|
|
|
208
279
|
handleTimelineElementSplit,
|
|
209
280
|
handleRazorSplit,
|
|
210
281
|
handleRazorSplitAll,
|
|
211
|
-
|
|
212
|
-
|
|
282
|
+
handleGsapRemoveAllKeyframes,
|
|
283
|
+
resolveKeyframeTarget,
|
|
213
284
|
selectedGsapAnimations,
|
|
214
285
|
handleGsapRemoveKeyframe,
|
|
286
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
287
|
+
handleGsapMoveKeyframe,
|
|
288
|
+
handleGsapResizeKeyframedTween,
|
|
215
289
|
handleGsapUpdateMeta,
|
|
216
290
|
handleGsapAddKeyframe,
|
|
217
291
|
handleGsapConvertToKeyframes,
|
|
218
292
|
buildDomSelectionForTimelineElement,
|
|
219
293
|
projectId,
|
|
220
294
|
activeCompPath,
|
|
295
|
+
domEditSelection,
|
|
221
296
|
],
|
|
222
297
|
);
|
|
223
298
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type EnableKeyframesSession,
|
|
6
6
|
} from "../hooks/useEnableKeyframes";
|
|
7
7
|
import { computeElementPercentage } from "../hooks/gsapShared";
|
|
8
|
+
import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
|
|
8
9
|
import {
|
|
9
10
|
getNextTimelineZoomPercent,
|
|
10
11
|
getTimelineZoomPercent,
|
|
@@ -89,6 +90,13 @@ export function TimelineToolbar({
|
|
|
89
90
|
onToggle: onToggleKeyframe,
|
|
90
91
|
} = useKeyframeToggle(domEditSession);
|
|
91
92
|
|
|
93
|
+
// Wire the "Add keyframe (K)" shortcut the toolbar advertises. Active only when
|
|
94
|
+
// there's a keyframeable selection; otherwise K stays JKL-pause in playback.
|
|
95
|
+
useKeyframeKeyboard({
|
|
96
|
+
enabled: STUDIO_KEYFRAMES_ENABLED && Boolean(onToggleKeyframe),
|
|
97
|
+
onAddKeyframe: onToggleKeyframe,
|
|
98
|
+
});
|
|
99
|
+
|
|
92
100
|
return (
|
|
93
101
|
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
|
|
94
102
|
<div className="flex items-center justify-between px-3 py-2">
|
|
@@ -71,7 +71,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
71
71
|
commitMutation,
|
|
72
72
|
selectedGsapAnimations,
|
|
73
73
|
handleGsapRemoveKeyframe,
|
|
74
|
-
|
|
74
|
+
handleGsapRemoveAllKeyframes,
|
|
75
|
+
handleGsapMoveKeyframeToPlayhead,
|
|
75
76
|
} = useDomEditContext();
|
|
76
77
|
const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
|
|
77
78
|
iframeRef,
|
|
@@ -489,7 +490,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
489
490
|
state={kfMenu}
|
|
490
491
|
onClose={() => setKfMenu(null)}
|
|
491
492
|
onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)}
|
|
492
|
-
onDeleteAll={(
|
|
493
|
+
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
|
|
494
|
+
onMoveToPlayhead={(_elId, pct) => animId && handleGsapMoveKeyframeToPlayhead(animId, pct)}
|
|
493
495
|
/>
|
|
494
496
|
)}
|
|
495
497
|
</>
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolveKeyframeDrag,
|
|
4
|
+
previewClipPct,
|
|
5
|
+
clampToNeighbors,
|
|
6
|
+
KEYFRAME_DRAG_THRESHOLD_PX,
|
|
7
|
+
} from "./keyframeDrag";
|
|
8
|
+
|
|
9
|
+
// Three keyframes at clip-% 20 / 40 / 60. The dragged one (index 1) is bounded
|
|
10
|
+
// by its neighbours at 20 and 60; first/last are bounded only by the clip.
|
|
11
|
+
const CLIP_PCTS = [20, 40, 60];
|
|
12
|
+
|
|
13
|
+
describe("resolveKeyframeDrag — click vs drag threshold", () => {
|
|
14
|
+
const base = {
|
|
15
|
+
clipWidthPx: 200,
|
|
16
|
+
draggedClipPct: 40,
|
|
17
|
+
draggedIndex: 1,
|
|
18
|
+
sortedClipPcts: CLIP_PCTS,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
it("treats sub-threshold movement as a click", () => {
|
|
22
|
+
const r = resolveKeyframeDrag({
|
|
23
|
+
...base,
|
|
24
|
+
pointerDownX: 100,
|
|
25
|
+
pointerUpX: 100 + (KEYFRAME_DRAG_THRESHOLD_PX - 1),
|
|
26
|
+
});
|
|
27
|
+
expect(r.kind).toBe("click");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("treats movement at/over the threshold as a drag", () => {
|
|
31
|
+
const r = resolveKeyframeDrag({
|
|
32
|
+
...base,
|
|
33
|
+
pointerDownX: 100,
|
|
34
|
+
pointerUpX: 100 + KEYFRAME_DRAG_THRESHOLD_PX + 1,
|
|
35
|
+
});
|
|
36
|
+
expect(r.kind).toBe("move");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("guards a zero-width clip (no division blowup) → click", () => {
|
|
40
|
+
const r = resolveKeyframeDrag({ ...base, clipWidthPx: 0, pointerDownX: 0, pointerUpX: 50 });
|
|
41
|
+
expect(r.kind).toBe("click");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("no-ops a past-threshold drag that resolves to the source percentage", () => {
|
|
45
|
+
// Over the px threshold, but on a huge clip the 5px maps to ~0.00125 clip%
|
|
46
|
+
// away — under the noop epsilon, so don't commit a churn write.
|
|
47
|
+
const r = resolveKeyframeDrag({
|
|
48
|
+
...base,
|
|
49
|
+
clipWidthPx: 1_000_000,
|
|
50
|
+
pointerDownX: 80,
|
|
51
|
+
pointerUpX: 80 + KEYFRAME_DRAG_THRESHOLD_PX + 1,
|
|
52
|
+
});
|
|
53
|
+
expect(r.kind).toBe("noop");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("resolveKeyframeDrag — pixel delta → clip%", () => {
|
|
58
|
+
// 200px wide clip → 2px per clip-%. Dragged diamond at clip 40%, pointer-down
|
|
59
|
+
// anchored at its pixel position (80px) for a clean delta.
|
|
60
|
+
const base = {
|
|
61
|
+
clipWidthPx: 200,
|
|
62
|
+
draggedClipPct: 40,
|
|
63
|
+
draggedIndex: 1,
|
|
64
|
+
sortedClipPcts: CLIP_PCTS,
|
|
65
|
+
pointerDownX: 80,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
it("maps a rightward drag to clip-%", () => {
|
|
69
|
+
// +20px → +10 clip% → clip 50% (within [20, 60]).
|
|
70
|
+
const r = resolveKeyframeDrag({ ...base, pointerUpX: 100 });
|
|
71
|
+
expect(r.kind).toBe("move");
|
|
72
|
+
expect(r.toClipPct).toBeCloseTo(50, 5);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("maps a leftward drag", () => {
|
|
76
|
+
// -20px → -10 clip% → clip 30%.
|
|
77
|
+
const r = resolveKeyframeDrag({ ...base, pointerUpX: 60 });
|
|
78
|
+
expect(r.toClipPct).toBeCloseTo(30, 5);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("resolveKeyframeDrag — neighbour + clip clamp", () => {
|
|
83
|
+
const base = { clipWidthPx: 200, pointerDownX: 80 };
|
|
84
|
+
|
|
85
|
+
it("an interior keyframe cannot pass its right neighbour", () => {
|
|
86
|
+
// Drag the middle (clip 40, index 1) far right → clamps just inside 60.
|
|
87
|
+
const r = resolveKeyframeDrag({
|
|
88
|
+
...base,
|
|
89
|
+
draggedClipPct: 40,
|
|
90
|
+
draggedIndex: 1,
|
|
91
|
+
sortedClipPcts: CLIP_PCTS,
|
|
92
|
+
pointerUpX: 5000,
|
|
93
|
+
});
|
|
94
|
+
expect(r.toClipPct).toBeLessThan(60);
|
|
95
|
+
expect(r.toClipPct).toBeGreaterThan(59); // epsilon inside, not equal/crossed
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("an interior keyframe cannot pass its left neighbour", () => {
|
|
99
|
+
const r = resolveKeyframeDrag({
|
|
100
|
+
...base,
|
|
101
|
+
draggedClipPct: 40,
|
|
102
|
+
draggedIndex: 1,
|
|
103
|
+
sortedClipPcts: CLIP_PCTS,
|
|
104
|
+
pointerUpX: -5000,
|
|
105
|
+
});
|
|
106
|
+
expect(r.toClipPct).toBeGreaterThan(20);
|
|
107
|
+
expect(r.toClipPct).toBeLessThan(21);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("the first keyframe is free to the clip start (0%) but bounded by the 2nd", () => {
|
|
111
|
+
// Index 0 dragged left past 0 → clamps to 0.
|
|
112
|
+
const left = resolveKeyframeDrag({
|
|
113
|
+
...base,
|
|
114
|
+
draggedClipPct: 20,
|
|
115
|
+
draggedIndex: 0,
|
|
116
|
+
sortedClipPcts: CLIP_PCTS,
|
|
117
|
+
pointerUpX: -5000,
|
|
118
|
+
});
|
|
119
|
+
expect(left.toClipPct).toBe(0);
|
|
120
|
+
// Dragged right past the 2nd keyframe (40) → clamps just inside it.
|
|
121
|
+
const right = resolveKeyframeDrag({
|
|
122
|
+
...base,
|
|
123
|
+
draggedClipPct: 20,
|
|
124
|
+
draggedIndex: 0,
|
|
125
|
+
sortedClipPcts: CLIP_PCTS,
|
|
126
|
+
pointerUpX: 5000,
|
|
127
|
+
});
|
|
128
|
+
expect(right.toClipPct).toBeLessThan(40);
|
|
129
|
+
expect(right.toClipPct).toBeGreaterThan(39);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("the last keyframe is free to the clip end (100%) but bounded by the 2nd-to-last", () => {
|
|
133
|
+
const right = resolveKeyframeDrag({
|
|
134
|
+
...base,
|
|
135
|
+
draggedClipPct: 60,
|
|
136
|
+
draggedIndex: 2,
|
|
137
|
+
sortedClipPcts: CLIP_PCTS,
|
|
138
|
+
pointerUpX: 5000,
|
|
139
|
+
});
|
|
140
|
+
expect(right.toClipPct).toBe(100);
|
|
141
|
+
const left = resolveKeyframeDrag({
|
|
142
|
+
...base,
|
|
143
|
+
draggedClipPct: 60,
|
|
144
|
+
draggedIndex: 2,
|
|
145
|
+
sortedClipPcts: CLIP_PCTS,
|
|
146
|
+
pointerUpX: -5000,
|
|
147
|
+
});
|
|
148
|
+
expect(left.toClipPct).toBeGreaterThan(40);
|
|
149
|
+
expect(left.toClipPct).toBeLessThan(41);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("a lone keyframe moves freely across the whole clip", () => {
|
|
153
|
+
const r = resolveKeyframeDrag({
|
|
154
|
+
...base,
|
|
155
|
+
draggedClipPct: 50,
|
|
156
|
+
draggedIndex: 0,
|
|
157
|
+
sortedClipPcts: [50],
|
|
158
|
+
pointerUpX: 5000,
|
|
159
|
+
});
|
|
160
|
+
expect(r.toClipPct).toBe(100);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("clampToNeighbors", () => {
|
|
165
|
+
it("pins to the midpoint when neighbours are tighter than 2·epsilon", () => {
|
|
166
|
+
// Neighbours at 10 and 10.5 → window [10.5, 10] inverts → midpoint 10.25.
|
|
167
|
+
expect(clampToNeighbors(50, [10, 10.2, 10.5], 1)).toBeCloseTo(10.25, 5);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("previewClipPct", () => {
|
|
172
|
+
it("follows the pointer in clip-% and clamps to neighbours", () => {
|
|
173
|
+
expect(
|
|
174
|
+
previewClipPct({
|
|
175
|
+
pointerDownX: 80,
|
|
176
|
+
pointerMoveX: 100,
|
|
177
|
+
clipWidthPx: 200,
|
|
178
|
+
draggedClipPct: 40,
|
|
179
|
+
draggedIndex: 1,
|
|
180
|
+
sortedClipPcts: CLIP_PCTS,
|
|
181
|
+
}),
|
|
182
|
+
).toBeCloseTo(50, 5);
|
|
183
|
+
// Far right → clamps just inside the right neighbour (60), not the clip edge.
|
|
184
|
+
expect(
|
|
185
|
+
previewClipPct({
|
|
186
|
+
pointerDownX: 80,
|
|
187
|
+
pointerMoveX: 5000,
|
|
188
|
+
clipWidthPx: 200,
|
|
189
|
+
draggedClipPct: 40,
|
|
190
|
+
draggedIndex: 1,
|
|
191
|
+
sortedClipPcts: CLIP_PCTS,
|
|
192
|
+
}),
|
|
193
|
+
).toBeLessThan(60);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure math for the timeline keyframe-diamond drag-to-retime gesture. Kept free
|
|
3
|
+
* of React/store so the gesture handler stays a thin orchestrator and the
|
|
4
|
+
* click-vs-drag + neighbor clamp are unit-testable in isolation.
|
|
5
|
+
*
|
|
6
|
+
* The diamond is positioned by clip-relative % (same basis it's drawn with), so
|
|
7
|
+
* this layer works entirely in clip-%: it converts the pointer pixel delta to a
|
|
8
|
+
* clip-% drop and clamps it between the dragged keyframe's neighbours (and the
|
|
9
|
+
* clip bounds). The clip-%→tween-% conversion and the move-vs-resize decision
|
|
10
|
+
* happen in the studio handler (it has the tween window + clip timing this layer
|
|
11
|
+
* deliberately doesn't), see `keyframeRetime.ts`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Screen-px the pointer must travel before a press counts as a drag (else click). */
|
|
15
|
+
export const KEYFRAME_DRAG_THRESHOLD_PX = 4;
|
|
16
|
+
/** Clip-% movement below this is treated as no change (drop == original). */
|
|
17
|
+
const NOOP_EPSILON_PCT = 0.1;
|
|
18
|
+
/** Gap (clip-%) kept between a dragged interior keyframe and each neighbour so it
|
|
19
|
+
* can't equal/cross them (which would reorder the keyframes). */
|
|
20
|
+
const NEIGHBOR_EPSILON_PCT = 0.5;
|
|
21
|
+
|
|
22
|
+
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Clamp a dragged keyframe's clip-% strictly between its immediate neighbours
|
|
26
|
+
* (with a small epsilon so it can't equal/cross them) and to the clip bounds.
|
|
27
|
+
*
|
|
28
|
+
* - Interior keyframe → bounded by both neighbours.
|
|
29
|
+
* - First keyframe (index 0) → left bound is the clip start (0%), so it's free to
|
|
30
|
+
* travel left toward/past the tween start (a boundary RESIZE the handler owns).
|
|
31
|
+
* - Last keyframe → right bound is the clip end (100%), free to travel right.
|
|
32
|
+
* - Lone keyframe → free across the whole clip [0, 100].
|
|
33
|
+
*/
|
|
34
|
+
export function clampToNeighbors(
|
|
35
|
+
clipPct: number,
|
|
36
|
+
sortedClipPcts: ReadonlyArray<number>,
|
|
37
|
+
draggedIndex: number,
|
|
38
|
+
): number {
|
|
39
|
+
const left =
|
|
40
|
+
draggedIndex > 0 ? (sortedClipPcts[draggedIndex - 1] ?? 0) + NEIGHBOR_EPSILON_PCT : 0;
|
|
41
|
+
const right =
|
|
42
|
+
draggedIndex < sortedClipPcts.length - 1
|
|
43
|
+
? (sortedClipPcts[draggedIndex + 1] ?? 100) - NEIGHBOR_EPSILON_PCT
|
|
44
|
+
: 100;
|
|
45
|
+
// Degenerate window (neighbours closer than 2·epsilon): pin to the midpoint so
|
|
46
|
+
// the result stays ordered between them.
|
|
47
|
+
if (left > right) return (left + right) / 2;
|
|
48
|
+
return clamp(clipPct, left, right);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface KeyframeDragResult {
|
|
52
|
+
/** `click`: under the drag threshold → seek. `noop`: moved but resolved onto
|
|
53
|
+
* the original keyframe → skip the commit. `move`: commit the retime. */
|
|
54
|
+
kind: "click" | "noop" | "move";
|
|
55
|
+
/** Clip-relative drop position, neighbour- and clip-clamped (only on `move`). */
|
|
56
|
+
toClipPct?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decide whether a diamond press was a click or a drag, and for a drag compute
|
|
61
|
+
* the neighbour-clamped clip-% drop position.
|
|
62
|
+
*
|
|
63
|
+
* - `draggedClipPct`: the dragged diamond's own clip-relative percentage.
|
|
64
|
+
* - `draggedIndex` / `sortedClipPcts`: index of the dragged keyframe within the
|
|
65
|
+
* clip's keyframes sorted by clip-%, used for the neighbour clamp.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveKeyframeDrag(opts: {
|
|
68
|
+
pointerDownX: number;
|
|
69
|
+
pointerUpX: number;
|
|
70
|
+
clipWidthPx: number;
|
|
71
|
+
draggedClipPct: number;
|
|
72
|
+
draggedIndex: number;
|
|
73
|
+
sortedClipPcts: ReadonlyArray<number>;
|
|
74
|
+
}): KeyframeDragResult {
|
|
75
|
+
const dx = opts.pointerUpX - opts.pointerDownX;
|
|
76
|
+
if (Math.abs(dx) < KEYFRAME_DRAG_THRESHOLD_PX || opts.clipWidthPx <= 0) {
|
|
77
|
+
return { kind: "click" };
|
|
78
|
+
}
|
|
79
|
+
const rawClipPct = opts.draggedClipPct + (dx / opts.clipWidthPx) * 100;
|
|
80
|
+
const toClipPct = clampToNeighbors(rawClipPct, opts.sortedClipPcts, opts.draggedIndex);
|
|
81
|
+
if (Math.abs(toClipPct - opts.draggedClipPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
|
|
82
|
+
return { kind: "move", toClipPct };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Live drag preview: the dragged diamond's clip-% as it follows the pointer,
|
|
87
|
+
* neighbour- and clip-clamped to match where the commit will land. Visual only —
|
|
88
|
+
* no runtime/GSAP hold (the #1763 flake).
|
|
89
|
+
*/
|
|
90
|
+
export function previewClipPct(opts: {
|
|
91
|
+
pointerDownX: number;
|
|
92
|
+
pointerMoveX: number;
|
|
93
|
+
clipWidthPx: number;
|
|
94
|
+
draggedClipPct: number;
|
|
95
|
+
draggedIndex: number;
|
|
96
|
+
sortedClipPcts: ReadonlyArray<number>;
|
|
97
|
+
}): number {
|
|
98
|
+
if (opts.clipWidthPx <= 0) return opts.draggedClipPct;
|
|
99
|
+
const dx = opts.pointerMoveX - opts.pointerDownX;
|
|
100
|
+
return clampToNeighbors(
|
|
101
|
+
opts.draggedClipPct + (dx / opts.clipWidthPx) * 100,
|
|
102
|
+
opts.sortedClipPcts,
|
|
103
|
+
opts.draggedIndex,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";
|
|
3
|
+
|
|
4
|
+
// Tween window [2, 6] (start 2s, duration 4s). Keyframes at tween-% 0/50/100 →
|
|
5
|
+
// absolute times 2 / 4 / 6. First + last carry an ease to prove it's preserved.
|
|
6
|
+
const KEYFRAMES: RetimeKeyframe[] = [
|
|
7
|
+
{ percentage: 0, properties: { x: 0 }, ease: "power1.out" },
|
|
8
|
+
{ percentage: 50, properties: { x: 50 } },
|
|
9
|
+
{ percentage: 100, properties: { x: 100 }, ease: "power2.in" },
|
|
10
|
+
];
|
|
11
|
+
const WINDOW = { tweenStart: 2, tweenDuration: 4 };
|
|
12
|
+
|
|
13
|
+
describe("resolveKeyframeRetime — move (within the tween window)", () => {
|
|
14
|
+
it("re-keys an interior keyframe to the tween-% of the drop", () => {
|
|
15
|
+
const r = resolveKeyframeRetime({
|
|
16
|
+
...WINDOW,
|
|
17
|
+
keyframes: KEYFRAMES,
|
|
18
|
+
draggedTweenPct: 50,
|
|
19
|
+
dropAbsTime: 3, // (3-2)/4 = 25%
|
|
20
|
+
});
|
|
21
|
+
expect(r.kind).toBe("move");
|
|
22
|
+
expect(r.toTweenPct).toBeCloseTo(25, 5);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("no-ops a drop that resolves onto the source keyframe", () => {
|
|
26
|
+
const r = resolveKeyframeRetime({
|
|
27
|
+
...WINDOW,
|
|
28
|
+
keyframes: KEYFRAMES,
|
|
29
|
+
draggedTweenPct: 50,
|
|
30
|
+
dropAbsTime: 4, // exactly 50%
|
|
31
|
+
});
|
|
32
|
+
expect(r.kind).toBe("noop");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("moves a flat (keyframe-less) tween without needing the keyframes array", () => {
|
|
36
|
+
const r = resolveKeyframeRetime({
|
|
37
|
+
...WINDOW,
|
|
38
|
+
keyframes: [],
|
|
39
|
+
draggedTweenPct: 100,
|
|
40
|
+
dropAbsTime: 5, // (5-2)/4 = 75%
|
|
41
|
+
});
|
|
42
|
+
expect(r.kind).toBe("move");
|
|
43
|
+
expect(r.toTweenPct).toBeCloseTo(75, 5);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
|
|
48
|
+
it("extends the LAST keyframe past the end, keeping others' absolute times", () => {
|
|
49
|
+
const r = resolveKeyframeRetime({
|
|
50
|
+
...WINDOW,
|
|
51
|
+
keyframes: KEYFRAMES,
|
|
52
|
+
draggedTweenPct: 100,
|
|
53
|
+
dropAbsTime: 8, // past end (6) → grow duration
|
|
54
|
+
});
|
|
55
|
+
expect(r.kind).toBe("resize");
|
|
56
|
+
expect(r.position).toBeCloseTo(2, 5); // start unchanged
|
|
57
|
+
expect(r.duration).toBeCloseTo(6, 5); // 8 - 2
|
|
58
|
+
// abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each
|
|
59
|
+
// existing keyframe's old→new tween-%; the commit re-keys in place (value +
|
|
60
|
+
// ease + _auto preserved by round-tripping the source node, not re-emitted here).
|
|
61
|
+
expect(r.pctRemap).toEqual([
|
|
62
|
+
{ from: 0, to: 0 },
|
|
63
|
+
{ from: 50, to: 33.3 },
|
|
64
|
+
{ from: 100, to: 100 },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("extends the FIRST keyframe before the start, shifting position earlier", () => {
|
|
69
|
+
const r = resolveKeyframeRetime({
|
|
70
|
+
...WINDOW,
|
|
71
|
+
keyframes: KEYFRAMES,
|
|
72
|
+
draggedTweenPct: 0,
|
|
73
|
+
dropAbsTime: 0.5, // before start (2) → move position back + grow duration
|
|
74
|
+
});
|
|
75
|
+
expect(r.kind).toBe("resize");
|
|
76
|
+
expect(r.position).toBeCloseTo(0.5, 5);
|
|
77
|
+
expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5
|
|
78
|
+
// abs 0.5/4/6 over [0.5,6] → 0 / 63.6 / 100.
|
|
79
|
+
expect(r.pctRemap).toEqual([
|
|
80
|
+
{ from: 0, to: 0 },
|
|
81
|
+
{ from: 50, to: 63.6 },
|
|
82
|
+
{ from: 100, to: 100 },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("resolveKeyframeRetime — single keyframe (both first and last)", () => {
|
|
88
|
+
const lone: RetimeKeyframe[] = [{ percentage: 100, properties: { x: 9 } }];
|
|
89
|
+
|
|
90
|
+
it("resizes right past the end", () => {
|
|
91
|
+
const r = resolveKeyframeRetime({
|
|
92
|
+
...WINDOW, // lone keyframe sits at abs 6
|
|
93
|
+
keyframes: lone,
|
|
94
|
+
draggedTweenPct: 100,
|
|
95
|
+
dropAbsTime: 9,
|
|
96
|
+
});
|
|
97
|
+
expect(r.kind).toBe("resize");
|
|
98
|
+
expect(r.position).toBeCloseTo(2, 5);
|
|
99
|
+
expect(r.duration).toBeCloseTo(7, 5);
|
|
100
|
+
expect(r.pctRemap).toEqual([{ from: 100, to: 100 }]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("resizes left before the start", () => {
|
|
104
|
+
const r = resolveKeyframeRetime({
|
|
105
|
+
...WINDOW,
|
|
106
|
+
keyframes: lone,
|
|
107
|
+
draggedTweenPct: 100,
|
|
108
|
+
dropAbsTime: 0.5,
|
|
109
|
+
});
|
|
110
|
+
expect(r.kind).toBe("resize");
|
|
111
|
+
expect(r.position).toBeCloseTo(0.5, 5);
|
|
112
|
+
expect(r.duration).toBeCloseTo(5.5, 5);
|
|
113
|
+
expect(r.pctRemap).toEqual([{ from: 100, to: 0 }]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("resolveKeyframeRetime — guards", () => {
|
|
118
|
+
it("no-ops a zero-duration tween", () => {
|
|
119
|
+
expect(
|
|
120
|
+
resolveKeyframeRetime({
|
|
121
|
+
tweenStart: 2,
|
|
122
|
+
tweenDuration: 0,
|
|
123
|
+
keyframes: KEYFRAMES,
|
|
124
|
+
draggedTweenPct: 50,
|
|
125
|
+
dropAbsTime: 3,
|
|
126
|
+
}).kind,
|
|
127
|
+
).toBe("noop");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("no-ops a boundary drop on a flat tween (nothing to remap)", () => {
|
|
131
|
+
expect(
|
|
132
|
+
resolveKeyframeRetime({
|
|
133
|
+
...WINDOW,
|
|
134
|
+
keyframes: [],
|
|
135
|
+
draggedTweenPct: 100,
|
|
136
|
+
dropAbsTime: 8,
|
|
137
|
+
}).kind,
|
|
138
|
+
).toBe("noop");
|
|
139
|
+
});
|
|
140
|
+
});
|