@hyperframes/studio 0.7.17 → 0.7.19

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.
Files changed (44) hide show
  1. package/dist/assets/hyperframes-player-DNLS_l47.js +459 -0
  2. package/dist/assets/{index-BDx28x9R.js → index-CURAsFZX.js} +1 -1
  3. package/dist/assets/index-DXWeH-EY.js +396 -0
  4. package/dist/assets/{index-BCAdHPz_.js → index-hueu10iU.js} +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1446 -866
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -7
  10. package/src/App.tsx +2 -1
  11. package/src/components/StudioPreviewArea.tsx +92 -17
  12. package/src/components/TimelineToolbar.tsx +8 -0
  13. package/src/components/editor/MotionPathOverlay.tsx +4 -2
  14. package/src/components/editor/keyframeDrag.test.ts +195 -0
  15. package/src/components/editor/keyframeDrag.ts +105 -0
  16. package/src/components/editor/keyframeRetime.test.ts +140 -0
  17. package/src/components/editor/keyframeRetime.ts +121 -0
  18. package/src/contexts/DomEditContext.tsx +12 -0
  19. package/src/contexts/TimelineEditContext.tsx +2 -0
  20. package/src/hooks/gsapDragCommit.ts +28 -1
  21. package/src/hooks/gsapPositionDetection.ts +98 -0
  22. package/src/hooks/gsapRuntimeBridge.test.ts +33 -0
  23. package/src/hooks/gsapRuntimeBridge.ts +41 -97
  24. package/src/hooks/useDomEditSession.ts +22 -0
  25. package/src/hooks/useDomEditWiring.ts +17 -0
  26. package/src/hooks/useDomSelection.ts +4 -1
  27. package/src/hooks/useGsapKeyframeOps.test.tsx +101 -0
  28. package/src/hooks/useGsapKeyframeOps.ts +63 -2
  29. package/src/hooks/useGsapSelectionHandlers.ts +72 -1
  30. package/src/hooks/useGsapTweenCache.ts +16 -7
  31. package/src/hooks/useKeyframeKeyboard.ts +29 -32
  32. package/src/hooks/useStudioSelectionPublisher.ts +102 -0
  33. package/src/player/components/KeyframeDiamondContextMenu.tsx +21 -2
  34. package/src/player/components/PlayerControls.tsx +40 -13
  35. package/src/player/components/Timeline.tsx +8 -0
  36. package/src/player/components/TimelineCanvas.tsx +7 -0
  37. package/src/player/components/TimelineClipDiamonds.tsx +109 -17
  38. package/src/player/components/timelineCallbacks.ts +6 -0
  39. package/src/utils/gsapSoftReload.test.ts +29 -0
  40. package/src/utils/gsapSoftReload.ts +19 -0
  41. package/src/utils/studioSelectionSnapshot.test.ts +47 -0
  42. package/src/utils/studioSelectionSnapshot.ts +72 -0
  43. package/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
  44. package/dist/assets/index-hiqLDAtz.js +0 -375
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.17",
3
+ "version": "0.7.19",
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/parsers": "0.7.17",
49
- "@hyperframes/player": "0.7.17",
50
- "@hyperframes/sdk": "0.7.17",
51
- "@hyperframes/studio-server": "0.7.17",
52
- "@hyperframes/core": "0.7.17"
49
+ "@hyperframes/core": "0.7.19",
50
+ "@hyperframes/player": "0.7.19",
51
+ "@hyperframes/parsers": "0.7.19",
52
+ "@hyperframes/studio-server": "0.7.19",
53
+ "@hyperframes/sdk": "0.7.19"
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.17"
68
+ "@hyperframes/producer": "0.7.19"
68
69
  },
69
70
  "peerDependencies": {
70
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -83,7 +83,7 @@ export function StudioApp() {
83
83
  const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
84
84
  const [compositionLoading, setCompositionLoading] = useState(true);
85
85
  const [refreshKey, setRefreshKey] = useState(0);
86
- const [, setPreviewDocumentVersion] = useState(0);
86
+ const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
87
87
  const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
88
88
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
89
89
  const activeCompPathRef = useRef(activeCompPath);
@@ -296,6 +296,7 @@ export function StudioApp() {
296
296
  projectIdRef: fileManager.projectIdRef,
297
297
  previewIframe,
298
298
  refreshKey,
299
+ previewDocumentVersion,
299
300
  rightPanelTab: panelLayout.rightPanelTab,
300
301
  applyStudioManualEditsToPreviewRef: previewPersistence.applyStudioManualEditsToPreviewRef,
301
302
  syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
@@ -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
- handleGsapDeleteAllForElement,
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: (elId: string) => {
162
- const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId;
163
- handleGsapDeleteAllForElement(`#${rawId}`);
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 cacheKey = domEditSelection?.id ?? "";
168
- const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
169
- const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
170
- const group = kf?.propertyGroup;
171
- const anim =
172
- (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
173
- selectedGsapAnimations.find((a) => a.keyframes);
174
- if (!anim) return;
175
- handleGsapRemoveKeyframe(anim.id, kf?.tweenPercentage ?? pct);
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
- handleGsapDeleteAllForElement,
212
- domEditSelection?.id,
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
- handleGsapDeleteAllForElement,
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={(elId) => handleGsapDeleteAllForElement(`#${elId}`)}
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
+ }