@hyperframes/studio 0.6.122-alpha.0 → 0.7.1

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 (100) hide show
  1. package/dist/assets/{index-i-MLqhmm.js → index-CnYOrmoA.js} +1 -1
  2. package/dist/assets/index-DzWIinxk.css +1 -0
  3. package/dist/assets/index-Z4bA3Fi0.js +374 -0
  4. package/dist/assets/{index-DSLrl2tB.js → index-wWkKTkIq.js} +24 -24
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3768 -2245
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -41
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditPreviewSync.ts +3 -0
  61. package/src/hooks/useDomEditSession.ts +0 -4
  62. package/src/hooks/useDomEditWiring.ts +1 -0
  63. package/src/hooks/useDomSelection.ts +12 -5
  64. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  65. package/src/hooks/useEnableKeyframes.ts +298 -45
  66. package/src/hooks/useGestureCommit.ts +121 -53
  67. package/src/hooks/useGestureRecording.ts +61 -42
  68. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  69. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  70. package/src/hooks/useGsapAnimationOps.ts +1 -1
  71. package/src/hooks/useGsapAwareEditing.ts +38 -40
  72. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  73. package/src/hooks/useGsapScriptCommits.ts +102 -6
  74. package/src/hooks/useGsapTweenCache.ts +12 -11
  75. package/src/hooks/useRazorSplit.ts +21 -2
  76. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  77. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  78. package/src/hooks/useServerConnection.ts +4 -1
  79. package/src/hooks/useStudioContextValue.ts +3 -2
  80. package/src/hooks/useStudioUrlState.ts +97 -37
  81. package/src/player/components/ShortcutsPanel.tsx +8 -0
  82. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  83. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  84. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  85. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  86. package/src/player/lib/mediaProbe.ts +7 -1
  87. package/src/player/store/playerStore.ts +38 -1
  88. package/src/telemetry/config.ts +3 -2
  89. package/src/utils/editDebugLog.ts +16 -0
  90. package/src/utils/generateId.ts +7 -0
  91. package/src/utils/gsapSoftReload.test.ts +217 -13
  92. package/src/utils/gsapSoftReload.ts +228 -50
  93. package/src/utils/studioHelpers.test.ts +31 -1
  94. package/src/utils/studioHelpers.ts +24 -0
  95. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  96. package/src/utils/studioPreviewHelpers.ts +30 -6
  97. package/src/utils/studioTelemetry.ts +3 -1
  98. package/dist/assets/index-Baz2T-9U.js +0 -269
  99. package/dist/assets/index-BvEhf7SS.css +0 -1
  100. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -8,13 +8,19 @@
8
8
  * Reads GSAP runtime values only (no CSS offset — it applies separately via translate).
9
9
  */
10
10
  import { useCallback } from "react";
11
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
11
+ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
12
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
13
  import { usePlayerStore } from "../player/store/playerStore";
14
14
  import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
15
15
  import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
16
+ import {
17
+ resolveTweenStart,
18
+ resolveTweenDuration,
19
+ isTimeWithinTween,
20
+ } from "../utils/globalTimeCompiler";
16
21
  import { POSITION_PROPS } from "./gsapRuntimeReaders";
17
22
  import { roundTo3 } from "../utils/rounding";
23
+ import { nearestPointOnPath } from "../components/editor/motionPathGeometry";
18
24
 
19
25
  export interface EnableKeyframesSession {
20
26
  domEditSelection: DomEditSelection | null;
@@ -37,6 +43,70 @@ export interface EnableKeyframesSession {
37
43
  ) => Promise<void>;
38
44
  }
39
45
 
46
+ /**
47
+ * Which animated properties to capture from the live element. Array-form keyframe
48
+ * tweens (`keyframes: [{x,y},…]`) leave `anim.properties` empty — the props live in
49
+ * the keyframe stops — so fall back to the union of the stops' keys, then to x/y.
50
+ */
51
+ export function animatedProps(anim: GsapAnimation | null): string[] {
52
+ if (!anim) return ["x", "y"];
53
+ const own = Object.keys(anim.properties ?? {});
54
+ if (own.length > 0) return own;
55
+ const stops = anim.keyframes?.keyframes;
56
+ if (stops?.length) {
57
+ const keys = new Set<string>();
58
+ for (const stop of stops) for (const k of Object.keys(stop.properties ?? {})) keys.add(k);
59
+ if (keys.size > 0) return [...keys];
60
+ }
61
+ return ["x", "y"];
62
+ }
63
+
64
+ /**
65
+ * Whether the playhead sits inside an animation's tween range. When the tween's
66
+ * start can't be resolved we don't block (the percentage falls back to clip range,
67
+ * preserving prior behavior for elements without explicit timing).
68
+ */
69
+ export function isPlayheadWithinTween(anim: GsapAnimation, currentTime: number): boolean {
70
+ const start = resolveTweenStart(anim);
71
+ if (start === null) return true;
72
+ return isTimeWithinTween(currentTime, start, resolveTweenDuration(anim));
73
+ }
74
+
75
+ /**
76
+ * Grow a keyframe tween's range to reach a playhead that sits outside it, and add a
77
+ * keyframe there. Existing keyframes keep their *absolute* timing (percentages
78
+ * rescale into the new range), so the current motion is preserved — the playhead
79
+ * just becomes a new hold at the start or end. Used when "add keyframe at playhead"
80
+ * fires beyond the tween instead of disabling the action.
81
+ */
82
+ export function buildExtendedKeyframes(
83
+ anim: GsapAnimation,
84
+ currentTime: number,
85
+ position: Record<string, number>,
86
+ ): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
87
+ const oldStart = resolveTweenStart(anim) ?? 0;
88
+ const oldDuration = resolveTweenDuration(anim);
89
+ const newStart = Math.min(oldStart, currentTime);
90
+ const newEnd = Math.max(oldStart + oldDuration, currentTime);
91
+ const newDuration = roundTo3(newEnd - newStart);
92
+ const toPct = (absoluteTime: number) =>
93
+ newDuration > 0
94
+ ? Math.max(
95
+ 0,
96
+ Math.min(100, Math.round(((absoluteTime - newStart) / newDuration) * 1000) / 10),
97
+ )
98
+ : 0;
99
+ const stops = anim.keyframes?.keyframes ?? [];
100
+ const rescaled: GsapPercentageKeyframe[] = stops.map((stop) => ({
101
+ percentage: toPct(oldStart + (stop.percentage / 100) * oldDuration),
102
+ properties: stop.properties,
103
+ ...(stop.ease ? { ease: stop.ease } : {}),
104
+ }));
105
+ const added: GsapPercentageKeyframe = { percentage: toPct(currentTime), properties: position };
106
+ const keyframes = [...rescaled, added].sort((a, b) => a.percentage - b.percentage);
107
+ return { position: roundTo3(newStart), duration: newDuration, keyframes };
108
+ }
109
+
40
110
  function readElementPosition(
41
111
  iframe: HTMLIFrameElement | null,
42
112
  sel: DomEditSelection,
@@ -55,7 +125,9 @@ function readElementPosition(
55
125
  const element = sel.element;
56
126
  if (!element?.isConnected || !gsap?.getProperty) return result;
57
127
 
58
- const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"];
128
+ // ponytail: a brand-new tween captures position only bundling opacity made it
129
+ // a mixed group that the position-only drag intercept couldn't resolve.
130
+ const props = animatedProps(anim);
59
131
  for (const prop of props) {
60
132
  const val = Number(gsap.getProperty(element, prop));
61
133
  if (!Number.isFinite(val)) continue;
@@ -65,18 +137,201 @@ function readElementPosition(
65
137
  return result;
66
138
  }
67
139
 
68
- async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAnimation[]> {
140
+ /**
141
+ * Range for a brand-new keyframe tween created via "Enable keyframes" on an element
142
+ * with no existing animation. "Add a keyframe" must land at the PLAYHEAD.
143
+ *
144
+ * The runtime auto-stamps `data-start="0"` + `data-duration=<rootDuration>` on every
145
+ * timeline element, so we can't treat `data-start` as authored timing (doing so put
146
+ * the keyframe at 0). Instead, clamp the playhead into the element's [start, end]
147
+ * range: the auto-stamp's full-composition range passes the playhead through
148
+ * unchanged, while a genuinely narrow authored clip still clamps sensibly.
149
+ */
150
+ export function resolveNewTweenRange(
151
+ authoredStart: string | undefined,
152
+ authoredDuration: string | undefined,
153
+ currentTime: number,
154
+ ): { start: number; duration: number } {
155
+ const t = Math.max(0, roundTo3(currentTime));
156
+ const start = authoredStart != null ? Number.parseFloat(authoredStart) : Number.NaN;
157
+ const duration = authoredDuration != null ? Number.parseFloat(authoredDuration) : Number.NaN;
158
+ if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) {
159
+ return { start: t, duration: 1 };
160
+ }
161
+ const end = start + duration;
162
+ const clampedStart = Math.min(Math.max(t, start), end);
163
+ return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) };
164
+ }
165
+
166
+ // Authoritative parse of the current source for `sel`. Returns `null` when the
167
+ // fetch can't run (no projectId / request failed) so callers can distinguish
168
+ // "unavailable" from a genuine empty result (e.g. after a delete-all). An empty
169
+ // array means the source was read and the element has no animations.
170
+ async function tryFetchAnimationsForElement(
171
+ sel: DomEditSelection,
172
+ ): Promise<GsapAnimation[] | null> {
69
173
  const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1];
70
- if (!projectId) return [];
174
+ if (!projectId) return null;
71
175
  const sourceFile = sel.sourceFile || "index.html";
72
176
  const parsed = await fetchParsedAnimations(projectId, sourceFile);
73
- if (!parsed) return [];
177
+ if (!parsed) return null;
74
178
  return getAnimationsForElement(parsed.animations, {
75
179
  id: sel.id,
76
180
  selector: sel.selector,
77
181
  });
78
182
  }
79
183
 
184
+ async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAnimation[]> {
185
+ return (await tryFetchAnimationsForElement(sel)) ?? [];
186
+ }
187
+
188
+ /**
189
+ * Apply "add keyframe at playhead" to a tween that already has x/y keyframes:
190
+ * toggle off an existing stop, add one at the playhead's tween-relative %, or —
191
+ * when the playhead sits outside the tween — extend the range to reach it (see
192
+ * buildExtendedKeyframes). Shared by native keyframe tweens and flat tweens that
193
+ * were just converted, so both behave identically.
194
+ */
195
+ async function applyKeyframeAtPlayhead(
196
+ session: EnableKeyframesSession,
197
+ sel: DomEditSelection,
198
+ kfAnim: GsapAnimation,
199
+ t: number,
200
+ iframe: HTMLIFrameElement | null,
201
+ ): Promise<void> {
202
+ if (!isPlayheadWithinTween(kfAnim, t)) {
203
+ const position = readElementPosition(iframe, sel, kfAnim);
204
+ const selector = selectorFromSelection(sel);
205
+ if (selector && Object.keys(position).length > 0 && session.commitMutation) {
206
+ const extended = buildExtendedKeyframes(kfAnim, t, position);
207
+ await session.commitMutation(
208
+ {
209
+ type: "replace-with-keyframes",
210
+ animationId: kfAnim.id,
211
+ targetSelector: selector,
212
+ position: extended.position,
213
+ duration: extended.duration,
214
+ keyframes: extended.keyframes,
215
+ ease: kfAnim.ease,
216
+ },
217
+ { label: "Add keyframe", softReload: true },
218
+ );
219
+ }
220
+ return;
221
+ }
222
+ const pct = computeElementPercentage(t, sel, kfAnim);
223
+ const existing = kfAnim.keyframes?.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
224
+ if (existing) {
225
+ session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
226
+ return;
227
+ }
228
+ if (session.handleGsapAddKeyframeBatch) {
229
+ const position = readElementPosition(iframe, sel, kfAnim);
230
+ if (Object.keys(position).length > 0) {
231
+ await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position);
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * A set() is an instantaneous hold. "Add keyframe at playhead" promotes it to a
238
+ * two-stop tween from the set's time to the playhead — the held value at 0%, the
239
+ * live value at 100% — giving the user something to animate. No-op if the playhead
240
+ * is at or before the set.
241
+ */
242
+ async function promoteSetToKeyframes(
243
+ session: EnableKeyframesSession,
244
+ sel: DomEditSelection,
245
+ setAnim: GsapAnimation,
246
+ t: number,
247
+ iframe: HTMLIFrameElement | null,
248
+ ): Promise<void> {
249
+ const selector = selectorFromSelection(sel);
250
+ const setStart = resolveTweenStart(setAnim) ?? 0;
251
+ if (!selector || !session.commitMutation || t <= setStart) return;
252
+ const endPosition = readElementPosition(iframe, sel, setAnim);
253
+ if (Object.keys(endPosition).length === 0) return;
254
+ const startPosition: Record<string, number> = {};
255
+ for (const key of Object.keys(endPosition)) {
256
+ const held = setAnim.properties?.[key];
257
+ if (typeof held === "number") startPosition[key] = held;
258
+ }
259
+ await session.commitMutation(
260
+ {
261
+ type: "replace-with-keyframes",
262
+ animationId: setAnim.id,
263
+ targetSelector: selector,
264
+ position: roundTo3(setStart),
265
+ duration: roundTo3(t - setStart),
266
+ keyframes: [
267
+ {
268
+ percentage: 0,
269
+ properties: Object.keys(startPosition).length > 0 ? startPosition : endPosition,
270
+ },
271
+ { percentage: 100, properties: endPosition },
272
+ ],
273
+ ease: setAnim.ease,
274
+ },
275
+ { label: "Add keyframe", softReload: true },
276
+ );
277
+ }
278
+
279
+ /**
280
+ * An arc (motionPath) tween — its waypoints are reconstructed onto `keyframes`, so
281
+ * it must be edited as waypoints (not x/y keyframes, which would break the curve).
282
+ * "Add keyframe at playhead" drops a waypoint where the element currently sits on
283
+ * the path, inserted at the matching segment so the curve is preserved. Outside the
284
+ * range, extend the duration so the motion reaches the playhead.
285
+ */
286
+ async function applyArcWaypointAtPlayhead(
287
+ session: EnableKeyframesSession,
288
+ sel: DomEditSelection,
289
+ arcAnim: GsapAnimation,
290
+ t: number,
291
+ iframe: HTMLIFrameElement | null,
292
+ ): Promise<void> {
293
+ if (!session.commitMutation) return;
294
+ if (!isPlayheadWithinTween(arcAnim, t)) {
295
+ const start = resolveTweenStart(arcAnim) ?? 0;
296
+ if (t > start) {
297
+ await session.commitMutation(
298
+ {
299
+ type: "update-meta",
300
+ animationId: arcAnim.id,
301
+ updates: { duration: roundTo3(t - start) },
302
+ },
303
+ { label: "Extend motion path", softReload: true },
304
+ );
305
+ }
306
+ return;
307
+ }
308
+ const live = readElementPosition(iframe, sel, arcAnim);
309
+ if (typeof live.x !== "number" || typeof live.y !== "number") return;
310
+ const liveX = live.x;
311
+ const liveY = live.y;
312
+ const nodes = (arcAnim.keyframes?.keyframes ?? [])
313
+ .map((k) => ({ x: k.properties.x, y: k.properties.y }))
314
+ .filter(
315
+ (p): p is { x: number; y: number } => typeof p.x === "number" && typeof p.y === "number",
316
+ );
317
+ // Don't duplicate a waypoint that already sits where the element is (e.g. at the
318
+ // path endpoints).
319
+ const WAYPOINT_MERGE_PX = 6;
320
+ if (nodes.some((n) => Math.hypot(n.x - liveX, n.y - liveY) <= WAYPOINT_MERGE_PX)) return;
321
+ const proj = nearestPointOnPath(liveX, liveY, nodes);
322
+ if (!proj) return;
323
+ await session.commitMutation(
324
+ {
325
+ type: "add-motion-path-point",
326
+ animationId: arcAnim.id,
327
+ index: proj.segIndex + 1,
328
+ x: liveX,
329
+ y: liveY,
330
+ },
331
+ { label: "Add waypoint", softReload: true },
332
+ );
333
+ }
334
+
80
335
  // fallow-ignore-next-line complexity
81
336
  export function useEnableKeyframes(
82
337
  sessionRef: React.RefObject<EnableKeyframesSession | undefined>,
@@ -90,41 +345,45 @@ export function useEnableKeyframes(
90
345
  const t = usePlayerStore.getState().currentTime;
91
346
  const iframe = session.previewIframeRef?.current ?? null;
92
347
 
93
- let anims = session.selectedGsapAnimations;
94
- if (anims.length === 0) {
95
- anims = await fetchAnimationsForElement(sel);
96
- }
97
-
98
- const kfAnim = anims.find((a) => a.keyframes);
99
- const flatAnim = anims.find((a) => !a.keyframes);
100
-
101
- if (kfAnim?.keyframes) {
102
- const pct = computeElementPercentage(t, sel);
103
- const existing = kfAnim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
104
- if (existing) {
105
- session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
106
- } else if (session.handleGsapAddKeyframeBatch) {
107
- const position = readElementPosition(iframe, sel, kfAnim);
108
- if (Object.keys(position).length > 0) {
109
- await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position);
110
- }
111
- }
112
- } else if (flatAnim) {
113
- const position = readElementPosition(iframe, sel, flatAnim);
114
- const hasPosition = Object.keys(position).length > 0;
348
+ // `selectedGsapAnimations` is a studio-side selection cache that can lag a
349
+ // mutation — e.g. right after a delete-all it may still hold the just-removed
350
+ // tween, which would route us into the wrong branch below (editing a tween
351
+ // that no longer exists in source). Prefer the authoritative parse of the
352
+ // current source; an empty parse is a valid "no animations" result and is
353
+ // honored. Fall back to the cache only when the fetch couldn't run at all
354
+ // (no projectId / request failed), preserving prior behavior offline.
355
+ const fetched = await tryFetchAnimationsForElement(sel);
356
+ const anims = fetched ?? session.selectedGsapAnimations;
115
357
 
116
- await session.handleGsapConvertToKeyframes(flatAnim.id, hasPosition ? position : undefined);
358
+ // An arc/motionPath tween carries reconstructed x/y keyframes too, so match it
359
+ // first and edit it as waypoints — treating it as plain keyframes would break
360
+ // the curve.
361
+ const arcAnim = anims.find((a) => a.arcPath);
362
+ const kfAnim = anims.find((a) => a.keyframes && !a.arcPath);
363
+ const setAnim = anims.find((a) => a.method === "set" && !a.keyframes && !a.arcPath);
364
+ const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && a.method !== "set");
117
365
 
118
- const pct = computeElementPercentage(t, sel);
119
- if (pct > 1 && pct < 99 && hasPosition && session.handleGsapAddKeyframeBatch) {
120
- await session.handleGsapAddKeyframeBatch(flatAnim.id, pct, position);
121
- await session.handleGsapAddKeyframeBatch(flatAnim.id, 100, position);
122
- }
366
+ if (arcAnim) {
367
+ await applyArcWaypointAtPlayhead(session, sel, arcAnim, t, iframe);
368
+ } else if (kfAnim) {
369
+ await applyKeyframeAtPlayhead(session, sel, kfAnim, t, iframe);
370
+ } else if (setAnim) {
371
+ await promoteSetToKeyframes(session, sel, setAnim, t, iframe);
372
+ } else if (flatAnim) {
373
+ // Convert the flat tween (to/from/fromTo) to its natural keyframes — no
374
+ // resolvedFromValues, so the 0%/100% stops keep the real start→end motion
375
+ // (passing the playhead value would flatten it). Then apply uniformly so an
376
+ // out-of-range playhead extends the range just like a keyframe tween.
377
+ await session.handleGsapConvertToKeyframes(flatAnim.id);
378
+ const converted = (await fetchAnimationsForElement(sel)).find((a) => a.keyframes);
379
+ if (converted) await applyKeyframeAtPlayhead(session, sel, converted, t, iframe);
123
380
  } else {
124
381
  const position = readElementPosition(iframe, sel, null);
125
- const pct = computeElementPercentage(t, sel);
126
- const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
127
- const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
382
+ const { start: elStart, duration: elDuration } = resolveNewTweenRange(
383
+ sel.dataAttributes?.start,
384
+ sel.dataAttributes?.duration,
385
+ t,
386
+ );
128
387
  const selector = selectorFromSelection(sel);
129
388
 
130
389
  if (!selector) {
@@ -135,19 +394,13 @@ export function useEnableKeyframes(
135
394
  if (Object.keys(position).length === 0) {
136
395
  position.x = 0;
137
396
  position.y = 0;
138
- position.opacity = 1;
139
397
  }
140
398
 
399
+ // One keyframe at the playhead — a single diamond capturing the current
400
+ // value. Motion comes from the user adding/dragging more keyframes later;
401
+ // creating 0%+100% up front showed two diamonds for a single "add keyframe".
141
402
  const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
142
403
  [{ percentage: 0, properties: { ...position } }];
143
- if (pct > 1 && pct < 99) {
144
- keyframes.push({ percentage: pct, properties: { ...position } });
145
- }
146
- keyframes.push({
147
- percentage: 100,
148
- properties: { ...position },
149
- auto: true,
150
- } as (typeof keyframes)[number]);
151
404
 
152
405
  if (session.commitMutation) {
153
406
  await session.commitMutation(
@@ -3,6 +3,7 @@
3
3
  * Extracted from App.tsx to keep file sizes under the 600-line limit.
4
4
  */
5
5
  import { useState, useCallback, useRef, useEffect } from "react";
6
+ import { editLog } from "../utils/editDebugLog";
6
7
  import { useGestureRecording } from "./useGestureRecording";
7
8
  import { simplifyGestureSamples } from "../utils/rdpSimplify";
8
9
  import { usePlayerStore } from "../player";
@@ -11,6 +12,46 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
11
12
  import { roundTo3 } from "../utils/rounding";
12
13
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
13
14
 
15
+ type RecordedKeyframe = { percentage: number; properties: Record<string, number | string> };
16
+
17
+ /**
18
+ * Split recorded keyframes into one keyframe-set per property group (position /
19
+ * scale / rotation / …), each keyframe carrying only that group's props.
20
+ *
21
+ * A mixed-prop gesture (e.g. x/y + opacity) emitted as ONE add-with-keyframes
22
+ * mutation parses back as an untagged legacy mixed tween, which breaks the
23
+ * position-only drag intercept (it can't find a pure position tween to edit).
24
+ * Emitting one tween per group keeps the position tween tagged and editable.
25
+ * Keyframes with no prop in a group are dropped from that group's set.
26
+ */
27
+ function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyframe[][] {
28
+ // Preserve first-seen group order for deterministic, stable mutation ordering.
29
+ const groupOrder: string[] = [];
30
+ const byGroup = new Map<string, RecordedKeyframe[]>();
31
+ for (const kf of keyframes) {
32
+ const perGroup = new Map<string, Record<string, number | string>>();
33
+ for (const [key, value] of Object.entries(kf.properties)) {
34
+ const group = classifyPropertyGroup(key);
35
+ let props = perGroup.get(group);
36
+ if (!props) {
37
+ props = {};
38
+ perGroup.set(group, props);
39
+ }
40
+ props[key] = value;
41
+ }
42
+ for (const [group, props] of perGroup) {
43
+ let set = byGroup.get(group);
44
+ if (!set) {
45
+ set = [];
46
+ byGroup.set(group, set);
47
+ groupOrder.push(group);
48
+ }
49
+ set.push({ percentage: kf.percentage, properties: props });
50
+ }
51
+ }
52
+ return groupOrder.map((group) => byGroup.get(group)!);
53
+ }
54
+
14
55
  // Minimal subset of the session used by gesture commit
15
56
  interface GestureSessionRef {
16
57
  domEditSelection: DomEditSelection | null;
@@ -121,76 +162,100 @@ export function useGestureCommit({
121
162
  ? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector)
122
163
  : undefined;
123
164
  if (existingPositionTween) {
124
- const tweenStart = existingPositionTween.resolvedStart ?? 0;
125
- const tweenDur = existingPositionTween.duration ?? duration;
126
- const tweenEnd = tweenStart + tweenDur;
127
- const recEnd = recStart + duration;
128
-
129
- // Only merge if the recording overlaps the existing tween's time range.
130
- // No overlap → fall through to add-with-keyframes (creates a separate tween).
131
- const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05;
132
-
133
- if (overlaps) {
134
- const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
135
- const rangeStartPct =
136
- tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0;
137
- const rangeEndPct =
138
- tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100;
139
-
140
- const preserved = existingKfs
141
- .filter(
142
- (kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
143
- )
144
- .map((kf) => ({
145
- percentage: kf.percentage,
146
- properties: kf.properties,
147
- ...(kf.ease ? { ease: kf.ease } : {}),
148
- }));
149
-
150
- const mapped = keyframes.map((kf) => ({
151
- percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
152
- properties: kf.properties,
153
- }));
154
-
155
- const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
156
-
165
+ if (existingPositionTween.method === "set") {
166
+ // A `set` is a static hold, not a tween to merge into — replace it with
167
+ // the recorded motion (which already starts from the set's position).
157
168
  await liveSession.commitMutation(
158
169
  {
159
170
  type: "replace-with-keyframes",
160
171
  animationId: existingPositionTween.id,
161
172
  targetSelector: selector,
162
- position:
163
- typeof existingPositionTween.position === "number"
164
- ? existingPositionTween.position
165
- : tweenStart,
166
- duration: tweenDur,
167
- keyframes: merged,
173
+ position: roundTo3(recStart),
174
+ duration: roundTo3(duration),
175
+ keyframes,
168
176
  },
169
- { label: "Gesture recording (merge)", softReload: true },
177
+ { label: "Gesture recording (replace set)", softReload: true },
170
178
  );
171
179
  } else {
180
+ const tweenStart = existingPositionTween.resolvedStart ?? 0;
181
+ const tweenDur = existingPositionTween.duration ?? duration;
182
+ const tweenEnd = tweenStart + tweenDur;
183
+ const recEnd = recStart + duration;
184
+
185
+ // Only merge if the recording overlaps the existing tween's time range.
186
+ // No overlap → fall through to add-with-keyframes (creates a separate tween).
187
+ const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05;
188
+
189
+ if (overlaps) {
190
+ const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
191
+ const rangeStartPct =
192
+ tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0;
193
+ const rangeEndPct =
194
+ tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100;
195
+
196
+ const preserved = existingKfs
197
+ .filter(
198
+ (kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
199
+ )
200
+ .map((kf) => ({
201
+ percentage: kf.percentage,
202
+ properties: kf.properties,
203
+ ...(kf.ease ? { ease: kf.ease } : {}),
204
+ }));
205
+
206
+ const mapped = keyframes.map((kf) => ({
207
+ percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
208
+ properties: kf.properties,
209
+ }));
210
+
211
+ const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
212
+
213
+ await liveSession.commitMutation(
214
+ {
215
+ type: "replace-with-keyframes",
216
+ animationId: existingPositionTween.id,
217
+ targetSelector: selector,
218
+ position:
219
+ typeof existingPositionTween.position === "number"
220
+ ? existingPositionTween.position
221
+ : tweenStart,
222
+ duration: tweenDur,
223
+ keyframes: merged,
224
+ },
225
+ { label: "Gesture recording (merge)", softReload: true },
226
+ );
227
+ } else {
228
+ // Emit one tween per property group so a mixed-prop gesture (e.g.
229
+ // x/y + opacity) doesn't collapse into an untagged legacy mixed
230
+ // tween that the position-only drag intercept can't edit.
231
+ for (const groupKfs of partitionKeyframesByGroup(keyframes)) {
232
+ await liveSession.commitMutation(
233
+ {
234
+ type: "add-with-keyframes",
235
+ targetSelector: selector,
236
+ position: roundTo3(recStart),
237
+ duration: roundTo3(duration),
238
+ keyframes: groupKfs,
239
+ },
240
+ { label: "Gesture recording (new range)", softReload: true },
241
+ );
242
+ }
243
+ }
244
+ }
245
+ } else {
246
+ // No existing tween — same per-group split as the new-range branch above.
247
+ for (const groupKfs of partitionKeyframesByGroup(keyframes)) {
172
248
  await liveSession.commitMutation(
173
249
  {
174
250
  type: "add-with-keyframes",
175
251
  targetSelector: selector,
176
252
  position: roundTo3(recStart),
177
253
  duration: roundTo3(duration),
178
- keyframes,
254
+ keyframes: groupKfs,
179
255
  },
180
- { label: "Gesture recording (new range)", softReload: true },
256
+ { label: "Gesture recording", softReload: true },
181
257
  );
182
258
  }
183
- } else {
184
- await liveSession.commitMutation(
185
- {
186
- type: "add-with-keyframes",
187
- targetSelector: selector,
188
- position: roundTo3(recStart),
189
- duration: roundTo3(duration),
190
- keyframes,
191
- },
192
- { label: "Gesture recording", softReload: true },
193
- );
194
259
  }
195
260
  }
196
261
  showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
@@ -206,6 +271,9 @@ export function useGestureCommit({
206
271
  }, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]);
207
272
 
208
273
  const handleToggleRecording = useCallback(() => {
274
+ editLog("gesture", gestureStateRef.current === "recording" ? "stop" : "start", {
275
+ id: domEditSessionRef.current.domEditSelection?.id,
276
+ });
209
277
  if (gestureStateRef.current === "recording") {
210
278
  void stopAndCommitRecording();
211
279
  return;