@hyperframes/studio 0.7.6 → 0.7.8

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 (58) hide show
  1. package/dist/assets/{index-B_NnmU6q.js → index--lOAjFJl.js} +1 -1
  2. package/dist/assets/{index-ysftPins.js → index-BSyZKYmH.js} +204 -203
  3. package/dist/assets/{index-DsBhGFPe.js → index-Dgeszckd.js} +1 -1
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +5 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +3650 -2431
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +7 -1
  11. package/src/components/editor/AnimationCard.tsx +6 -0
  12. package/src/components/editor/DomEditOverlay.tsx +4 -12
  13. package/src/components/editor/EaseCurveSection.tsx +175 -99
  14. package/src/components/editor/GsapAnimationSection.tsx +2 -0
  15. package/src/components/editor/KeyframeEaseList.tsx +67 -3
  16. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  17. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  18. package/src/components/editor/PropertyPanel.tsx +23 -2
  19. package/src/components/editor/Transform3DCube.tsx +313 -0
  20. package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +11 -35
  22. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  23. package/src/components/editor/marqueeCommit.ts +63 -20
  24. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  25. package/src/components/editor/propertyPanelHelpers.ts +46 -18
  26. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  27. package/src/components/editor/transform3dProjection.test.ts +99 -0
  28. package/src/components/editor/transform3dProjection.ts +172 -0
  29. package/src/components/editor/useMotionPathData.ts +2 -1
  30. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  31. package/src/components/sidebar/AssetsTab.tsx +364 -266
  32. package/src/components/sidebar/AudioRow.tsx +202 -0
  33. package/src/components/sidebar/assetHelpers.ts +40 -0
  34. package/src/contexts/DomEditContext.tsx +8 -0
  35. package/src/hooks/gsapDragCommit.test.ts +9 -5
  36. package/src/hooks/gsapDragCommit.ts +129 -9
  37. package/src/hooks/gsapRuntimeBridge.ts +22 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  39. package/src/hooks/gsapRuntimeKeyframes.ts +63 -5
  40. package/src/hooks/gsapRuntimePatch.ts +162 -35
  41. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  42. package/src/hooks/useDomEditCommits.ts +0 -2
  43. package/src/hooks/useDomEditSession.ts +24 -2
  44. package/src/hooks/useDomEditWiring.ts +3 -0
  45. package/src/hooks/useDomGeometryCommits.ts +3 -31
  46. package/src/hooks/useGestureCommit.ts +0 -4
  47. package/src/hooks/useGsapAwareEditing.ts +31 -5
  48. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  49. package/src/hooks/useGsapScriptCommits.ts +0 -12
  50. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  51. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  52. package/src/hooks/useGsapTweenCache.ts +125 -42
  53. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  54. package/src/hooks/useRazorSplit.ts +0 -3
  55. package/src/utils/marqueeGeometry.test.ts +15 -98
  56. package/src/utils/marqueeGeometry.ts +1 -158
  57. package/dist/assets/index-wJZf6FK3.css +0 -1
  58. package/src/utils/editDebugLog.ts +0 -9
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Unified helper for committing any GSAP property value from the design panel.
3
3
  *
4
- * Handles three cases:
5
- * 1. Animation with keyframes add-keyframe at current percentage
6
- * 2. Flat animation (no keyframes) convert to keyframes, then add-keyframe
7
- * 3. No animationcreate tl.to(), convert to keyframes, then add-keyframe
4
+ * Routing depends on whether the element is animated (has keyframes on any tween):
5
+ * - Animated write the value into a keyframe at the current playhead (convert a
6
+ * flat tween first if needed). An existing static `set` auto-converts to keyframes.
7
+ * - Static (no keyframes anywhere) persist as a `tl.set`, NEVER keyframes same
8
+ * as manual drag / resize / rotate. Updates an existing set or creates one.
8
9
  */
9
10
  import { useCallback } from "react";
10
11
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
@@ -12,6 +13,7 @@ import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
12
13
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
14
  import { usePlayerStore } from "../player/store/playerStore";
14
15
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
16
+ import type { SetPatchProps } from "./gsapRuntimePatch";
15
17
  import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
16
18
 
17
19
  interface CommitAnimatedPropertyDeps {
@@ -47,6 +49,7 @@ function pickBestAnimation(
47
49
  const currentTime = usePlayerStore.getState().currentTime;
48
50
  const targetGroup = property ? classifyPropertyGroup(property) : undefined;
49
51
 
52
+ // fallow-ignore-next-line complexity
50
53
  const scored = animations.map((a) => {
51
54
  let score = 0;
52
55
  if (targetGroup && a.propertyGroup === targetGroup) score += 20;
@@ -62,96 +65,271 @@ function pickBestAnimation(
62
65
  return scored[0]?.anim;
63
66
  }
64
67
 
68
+ /**
69
+ * Auto-keyframe a just-updated static `set`: if the element is already animated
70
+ * (its clip carries keyframes on another tween), convert the set to keyframes so
71
+ * subsequent edits at other playheads interpolate — matching the drag / resize /
72
+ * rotate UX. Purely static elements (no other keyframes) are left as a set.
73
+ */
74
+ async function maybeAutoKeyframeSet(
75
+ selection: DomEditSelection,
76
+ setAnim: GsapAnimation,
77
+ animations: GsapAnimation[],
78
+ commit: NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>,
79
+ ): Promise<void> {
80
+ const animatedTween = animations.find((a) => a.keyframes && a.id !== setAnim.id);
81
+ if (!animatedTween) return;
82
+ await commit(
83
+ selection,
84
+ {
85
+ type: "convert-to-keyframes",
86
+ animationId: setAnim.id,
87
+ duration: animatedTween.duration ?? 1,
88
+ },
89
+ { label: "Keyframe 3D transform", softReload: true },
90
+ );
91
+ }
92
+
93
+ type Commit = NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>;
94
+
95
+ /** Merge ALL props into the static `set` in ONE commit (value-only, instant), then
96
+ * auto-keyframe. One mutation — a per-property loop would shift the set's
97
+ * group-derived id mid-way (e.g. reset adding `scale` to a rotation set), 404-ing
98
+ * the next update. */
99
+ async function commitSetProps(
100
+ selection: DomEditSelection,
101
+ setAnim: GsapAnimation,
102
+ propEntries: [string, number | string][],
103
+ selector: string | null,
104
+ animations: GsapAnimation[],
105
+ commit: Commit,
106
+ ): Promise<void> {
107
+ const properties = Object.fromEntries(propEntries);
108
+ const numericProps: SetPatchProps = {};
109
+ for (const [k, v] of propEntries) {
110
+ if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
111
+ }
112
+ const instantPatch =
113
+ selector && Object.keys(numericProps).length > 0
114
+ ? {
115
+ selector,
116
+ change: {
117
+ kind: (setAnim.global ? "global-set" : "set") as "set" | "global-set",
118
+ props: numericProps,
119
+ },
120
+ }
121
+ : undefined;
122
+ await commit(
123
+ selection,
124
+ { type: "update-properties", animationId: setAnim.id, properties },
125
+ { label: "Set 3D transform", softReload: true, ...(instantPatch ? { instantPatch } : {}) },
126
+ );
127
+ await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
128
+ }
129
+
130
+ /**
131
+ * Static element (no keyframes on ANY of its tweens): persist the 3D props as a
132
+ * `tl.set` — NEVER keyframes. Mirrors manual drag / resize / rotate, which `tl.set`
133
+ * a static element instead of animating it. Updates an existing `set` in place, or
134
+ * creates a dedicated `set` at position 0 when the element has none.
135
+ */
136
+ async function commitStaticSet(
137
+ selection: DomEditSelection,
138
+ propEntries: [string, number | string][],
139
+ selector: string | null,
140
+ animations: GsapAnimation[],
141
+ commit: Commit,
142
+ ): Promise<void> {
143
+ if (!selector) return;
144
+ // Update an existing `set` in ONE batched commit — NEVER a flat `to`/`from`. A
145
+ // set's id is GROUP-derived, so a per-prop loop shifts it the instant a new-group
146
+ // prop lands (e.g. `scale` onto a rotation set), 404-ing the next prop; commitSetProps
147
+ // sends them together. A static element with no set gets a dedicated `set` carrying
148
+ // ALL props in ONE `add`.
149
+ const existingSet = animations.find((a) => a.method === "set" && a.targetSelector === selector);
150
+ if (existingSet) {
151
+ await commitSetProps(selection, existingSet, propEntries, selector, animations, commit);
152
+ return;
153
+ }
154
+ // Base `gsap.set` (off-timeline) — a static hold with no 0% keyframe marker, so
155
+ // adjusting a 3D transform on a non-keyframed element doesn't drop a keyframe on
156
+ // the timeline (matches the manual-drag UX). The global-set instant patch applies
157
+ // it straight to the element so the first edit shows with no soft-reload flash.
158
+ const numericProps: SetPatchProps = {};
159
+ for (const [k, v] of propEntries) {
160
+ if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
161
+ }
162
+ await commit(
163
+ selection,
164
+ {
165
+ type: "add",
166
+ targetSelector: selector,
167
+ method: "set",
168
+ position: 0,
169
+ properties: Object.fromEntries(propEntries),
170
+ global: true,
171
+ },
172
+ {
173
+ label: "Set 3D transform",
174
+ softReload: true,
175
+ ...(Object.keys(numericProps).length > 0
176
+ ? {
177
+ instantPatch: {
178
+ selector,
179
+ change: { kind: "global-set" as const, props: numericProps },
180
+ },
181
+ }
182
+ : {}),
183
+ },
184
+ );
185
+ }
186
+
187
+ /** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */
188
+ // fallow-ignore-next-line complexity
189
+ async function commitKeyframeProps(
190
+ selection: DomEditSelection,
191
+ anim: GsapAnimation,
192
+ props: Record<string, number | string>,
193
+ propEntries: [string, number | string][],
194
+ primaryProp: string,
195
+ selector: string | null,
196
+ iframe: HTMLIFrameElement | null,
197
+ commit: Commit,
198
+ ): Promise<void> {
199
+ if (!anim.keyframes) {
200
+ await commit(
201
+ selection,
202
+ { type: "convert-to-keyframes", animationId: anim.id },
203
+ { label: "Convert to keyframes", skipReload: true },
204
+ );
205
+ }
206
+ const pct = computeElementPercentage(usePlayerStore.getState().currentTime, selection, anim);
207
+ const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
208
+ const properties: Record<string, number | string> = { ...runtimeProps, ...props };
209
+
210
+ const backfillDefaults: Record<string, number | string> = { ...runtimeProps };
211
+ for (const [property, value] of propEntries) {
212
+ if (!(property in runtimeProps) && selector) {
213
+ const cssVal = readGsapProperty(iframe, selector, property);
214
+ if (cssVal != null) backfillDefaults[property] = cssVal;
215
+ }
216
+ backfillDefaults[property] = value;
217
+ }
218
+
219
+ const existingKf = anim.keyframes?.keyframes.some((kf) => Math.abs(kf.percentage - pct) < 0.05);
220
+ // Rebuild the live keyframe tween in place so the edit shows instantly (no flash);
221
+ // rebuildKeyframeTween declines → soft reload if the tween can't be safely rebuilt.
222
+ const numericProps: Record<string, number> = {};
223
+ for (const [k, v] of Object.entries(properties)) {
224
+ if (typeof v === "number") numericProps[k] = v;
225
+ }
226
+ const instantPatch =
227
+ selector && Object.keys(numericProps).length > 0
228
+ ? { selector, change: { kind: "keyframe-rebuild" as const, pct, props: numericProps } }
229
+ : undefined;
230
+ await commit(
231
+ selection,
232
+ existingKf
233
+ ? { type: "update-keyframe", animationId: anim.id, percentage: pct, properties }
234
+ : {
235
+ type: "add-keyframe",
236
+ animationId: anim.id,
237
+ percentage: pct,
238
+ properties,
239
+ backfillDefaults,
240
+ },
241
+ {
242
+ label: `Edit ${primaryProp} (keyframe ${pct}%)`,
243
+ softReload: true,
244
+ ...(instantPatch ? { instantPatch } : {}),
245
+ },
246
+ );
247
+ }
248
+
65
249
  export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
66
- const {
67
- selectedGsapAnimations,
68
- gsapCommitMutation,
69
- addGsapAnimation,
70
- previewIframeRef,
71
- bumpGsapCache,
72
- } = deps;
250
+ const { selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache } = deps;
73
251
 
74
- const commitAnimatedProperty = useCallback(
75
- async (
76
- selection: DomEditSelection,
77
- property: string,
78
- value: number | string,
79
- ): Promise<void> => {
252
+ const commitAnimatedProperties = useCallback(
253
+ async (selection: DomEditSelection, props: Record<string, number | string>): Promise<void> => {
80
254
  if (!gsapCommitMutation) return;
255
+ const propEntries = Object.entries(props);
256
+ if (propEntries.length === 0) return;
257
+ const primaryProp = propEntries[0]![0];
81
258
 
82
259
  const iframe = previewIframeRef.current;
83
260
  const selector = selectorFromSelection(selection);
84
261
 
85
- let anim: GsapAnimation | undefined = pickBestAnimation(
262
+ const anim: GsapAnimation | undefined = pickBestAnimation(
86
263
  selectedGsapAnimations,
87
264
  selector,
88
- property,
265
+ primaryProp,
89
266
  );
267
+ // Whether the element is animated at all. A 3D edit only creates/edits
268
+ // keyframes when it IS — a static element (no keyframes on any of its tweens)
269
+ // gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate).
270
+ const elementHasKeyframes = selectedGsapAnimations.some((a) => !!a.keyframes);
90
271
 
91
- // Case 3: No animation create one first
92
- if (!anim) {
93
- addGsapAnimation(selection, "to");
94
- // The addGsapAnimation triggers a reload. We need to wait for the cache
95
- // to update. Use a small delay then bump cache to re-fetch.
96
- await new Promise((r) => setTimeout(r, 500));
97
- bumpGsapCache();
98
- // After creation, we can't proceed in this call the animation isn't
99
- // in our local state yet. The user's next edit will find it.
100
- // For immediate feedback, trigger a convert-to-keyframes on the new animation.
101
- return;
102
- }
272
+ // The picked anim comes from the (possibly stale) panel cache: if keyframes
273
+ // were just removed or the script changed underneath us, its id is gone
274
+ // server-side and the commit 404s. The raw commit already toasts; we catch
275
+ // so the rejection doesn't escape as an uncaught promise, and bump the cache
276
+ // so selectedGsapAnimations re-syncs and the user's next edit self-heals.
277
+ try {
278
+ // Existing static hold — merge the props into the `set`, then auto-keyframe
279
+ // ONLY if the element is already animated (maybeAutoKeyframeSet no-ops if not).
280
+ if (anim?.method === "set") {
281
+ await commitSetProps(
282
+ selection,
283
+ anim,
284
+ propEntries,
285
+ selector,
286
+ selectedGsapAnimations,
287
+ gsapCommitMutation,
288
+ );
289
+ return;
290
+ }
103
291
 
104
- // Case 2: Flat animation convert to keyframes first
105
- if (!anim.keyframes) {
106
- await gsapCommitMutation(
292
+ // Static element persist as a `tl.set`, never keyframes (incl. the
293
+ // no-animation case, which now creates a set instead of a keyframed tween).
294
+ if (!elementHasKeyframes) {
295
+ await commitStaticSet(
296
+ selection,
297
+ propEntries,
298
+ selector,
299
+ selectedGsapAnimations,
300
+ gsapCommitMutation,
301
+ );
302
+ return;
303
+ }
304
+
305
+ // Animated element — write ALL props into ONE keyframe so a multi-axis cube
306
+ // edit doesn't race into adjacent duplicates.
307
+ if (!anim) {
308
+ bumpGsapCache();
309
+ return;
310
+ }
311
+ await commitKeyframeProps(
107
312
  selection,
108
- { type: "convert-to-keyframes", animationId: anim.id },
109
- { label: "Convert to keyframes", skipReload: true },
313
+ anim,
314
+ props,
315
+ propEntries,
316
+ primaryProp,
317
+ selector,
318
+ iframe,
319
+ gsapCommitMutation,
110
320
  );
321
+ } catch {
322
+ bumpGsapCache();
111
323
  }
112
-
113
- const pct = computeElementPercentage(usePlayerStore.getState().currentTime, selection, anim);
114
-
115
- // Read all currently animated properties from runtime for backfill
116
- const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
117
-
118
- // Build the properties object: all runtime props + the new value
119
- const properties: Record<string, number | string> = { ...runtimeProps };
120
- properties[property] = value;
121
-
122
- // Compute backfill defaults for properties not in existing keyframes
123
- const backfillDefaults: Record<string, number | string> = { ...runtimeProps };
124
- if (!(property in runtimeProps) && selector) {
125
- const cssVal = readGsapProperty(iframe, selector, property);
126
- if (cssVal != null) backfillDefaults[property] = cssVal;
127
- }
128
- backfillDefaults[property] = typeof value === "number" ? value : value;
129
-
130
- const existingKf = anim.keyframes?.keyframes.some(
131
- (kf) => Math.abs(kf.percentage - pct) < 0.05,
132
- );
133
-
134
- await gsapCommitMutation(
135
- selection,
136
- existingKf
137
- ? {
138
- type: "update-keyframe",
139
- animationId: anim.id,
140
- percentage: pct,
141
- properties,
142
- }
143
- : {
144
- type: "add-keyframe",
145
- animationId: anim.id,
146
- percentage: pct,
147
- properties,
148
- backfillDefaults,
149
- },
150
- { label: `Edit ${property} (keyframe ${pct}%)`, softReload: true },
151
- );
152
324
  },
153
- [selectedGsapAnimations, gsapCommitMutation, addGsapAnimation, previewIframeRef, bumpGsapCache],
325
+ [selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache],
326
+ );
327
+
328
+ const commitAnimatedProperty = useCallback(
329
+ (selection: DomEditSelection, property: string, value: number | string) =>
330
+ commitAnimatedProperties(selection, { [property]: value }),
331
+ [commitAnimatedProperties],
154
332
  );
155
333
 
156
- return commitAnimatedProperty;
334
+ return { commitAnimatedProperty, commitAnimatedProperties };
157
335
  }
@@ -301,7 +301,6 @@ export function useDomEditCommits({
301
301
 
302
302
  const {
303
303
  handleDomPathOffsetCommit,
304
- handleDomGroupPathOffsetCommit,
305
304
  handleDomBoxSizeCommit,
306
305
  handleDomRotationCommit,
307
306
  handleDomManualEditsReset,
@@ -340,7 +339,6 @@ export function useDomEditCommits({
340
339
  handleDomAddTextField,
341
340
  handleDomRemoveTextField,
342
341
  handleDomPathOffsetCommit,
343
- handleDomGroupPathOffsetCommit,
344
342
  handleDomBoxSizeCommit,
345
343
  handleDomRotationCommit,
346
344
  handleDomManualEditsReset,
@@ -215,7 +215,6 @@ export function useDomEditSession({
215
215
  handleDomTextFieldStyleCommit,
216
216
  handleDomAddTextField,
217
217
  handleDomRemoveTextField,
218
- handleDomGroupPathOffsetCommit,
219
218
  handleDomBoxSizeCommit,
220
219
  handleDomManualEditsReset,
221
220
  handleDomEditElementDelete,
@@ -368,9 +367,11 @@ export function useDomEditSession({
368
367
 
369
368
  const {
370
369
  handleGsapAwarePathOffsetCommit,
370
+ handleGsapAwareGroupPathOffsetCommit,
371
371
  handleGsapAwareBoxSizeCommit,
372
372
  handleGsapAwareRotationCommit,
373
373
  commitAnimatedProperty,
374
+ commitAnimatedProperties,
374
375
  handleSetArcPath,
375
376
  handleUpdateArcSegment,
376
377
  handleUnroll,
@@ -410,6 +411,25 @@ export function useDomEditSession({
410
411
  [gsapCommitMutation, domEditSelectionRef],
411
412
  );
412
413
 
414
+ // Apply one ease to every segment at once (AE select-all + F9): set easeEach
415
+ // and strip per-keyframe overrides in a single mutation.
416
+ const handleSetAllKeyframeEases = useCallback(
417
+ (animationId: string, ease: string) => {
418
+ const sel = domEditSelectionRef.current;
419
+ if (!sel) return;
420
+ gsapCommitMutation(
421
+ sel,
422
+ {
423
+ type: "update-meta",
424
+ animationId,
425
+ updates: { easeEach: ease, resetKeyframeEases: true },
426
+ },
427
+ { label: "Apply ease to all segments", softReload: true },
428
+ );
429
+ },
430
+ [gsapCommitMutation, domEditSelectionRef],
431
+ );
432
+
413
433
  return {
414
434
  // State
415
435
  domEditSelection,
@@ -433,7 +453,7 @@ export function useDomEditSession({
433
453
  handleDomAttributeLiveCommit,
434
454
  handleDomHtmlAttributeCommit,
435
455
  handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
436
- handleDomGroupPathOffsetCommit,
456
+ handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
437
457
  handleDomZIndexReorderCommit,
438
458
  handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit,
439
459
  handleDomRotationCommit: handleGsapAwareRotationCommit,
@@ -477,7 +497,9 @@ export function useDomEditSession({
477
497
  handleGsapRemoveAllKeyframes,
478
498
  handleResetSelectedElementKeyframes,
479
499
  handleUpdateKeyframeEase,
500
+ handleSetAllKeyframeEases,
480
501
  commitAnimatedProperty,
502
+ commitAnimatedProperties,
481
503
  handleSetArcPath,
482
504
  handleUpdateArcSegment,
483
505
  handleUnroll,
@@ -197,6 +197,9 @@ export function useDomEditWiring({
197
197
  ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
198
198
  : null,
199
199
  gsapCacheVersion,
200
+ // Pass the preview iframe so class/selector tweens (e.g. `.dot`) resolve to
201
+ // the live element and surface in the inspector — not just by #id match.
202
+ previewIframeRef,
200
203
  );
201
204
 
202
205
  // ── Telemetry & fallback ──
@@ -16,11 +16,10 @@ import {
16
16
  buildClearBoxSizePatches,
17
17
  buildClearRotationPatches,
18
18
  } from "../components/editor/manualEditsDomPatches";
19
- import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
20
19
  import type { PatchOperation } from "../utils/sourcePatcher";
21
20
  import { isElementGsapTargeted } from "./gsapTargetCache";
22
21
 
23
- export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
22
+ const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
24
23
  "This element is GSAP-animated — dragging via CSS would corrupt keyframes";
25
24
 
26
25
  // ── Hook ──
@@ -46,7 +45,8 @@ export function useDomGeometryCommits({
46
45
  // elements fall through to commitPositionPatchToHtml → persistDomEditOperations →
47
46
  // onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done).
48
47
  // Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR.
49
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
48
+ const gsapTargeted = isElementGsapTargeted(previewIframeRef.current, selection.element);
49
+ if (gsapTargeted) {
50
50
  const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
51
51
  showToast(error.message, "error");
52
52
  return Promise.reject(error);
@@ -60,33 +60,6 @@ export function useDomGeometryCommits({
60
60
  [commitPositionPatchToHtml, previewIframeRef, showToast],
61
61
  );
62
62
 
63
- const handleDomGroupPathOffsetCommit = useCallback(
64
- (updates: DomEditGroupPathOffsetCommit[]) => {
65
- if (updates.length === 0) return Promise.resolve();
66
- const blockedUpdate = updates.find(({ selection }) =>
67
- isElementGsapTargeted(previewIframeRef.current, selection.element),
68
- );
69
- if (blockedUpdate) {
70
- const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
71
- showToast(error.message, "error");
72
- return Promise.reject(error);
73
- }
74
- const coalesceKey = updates
75
- .map((u) => getDomEditTargetKey(u.selection))
76
- .sort()
77
- .join(":");
78
- const saves = updates.map(({ selection, next }) => {
79
- applyStudioPathOffset(selection.element, next);
80
- return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
81
- label: `Move ${updates.length} layers`,
82
- coalesceKey: `group-path-offset:${coalesceKey}`,
83
- });
84
- });
85
- return Promise.all(saves).then(() => undefined);
86
- },
87
- [commitPositionPatchToHtml, previewIframeRef, showToast],
88
- );
89
-
90
63
  const handleDomBoxSizeCommit = useCallback(
91
64
  (selection: DomEditSelection, next: { width: number; height: number }) => {
92
65
  if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
@@ -142,7 +115,6 @@ export function useDomGeometryCommits({
142
115
 
143
116
  return {
144
117
  handleDomPathOffsetCommit,
145
- handleDomGroupPathOffsetCommit,
146
118
  handleDomBoxSizeCommit,
147
119
  handleDomRotationCommit,
148
120
  handleDomManualEditsReset,
@@ -3,7 +3,6 @@
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";
7
6
  import { useGestureRecording } from "./useGestureRecording";
8
7
  import { simplifyGestureSamples } from "../utils/rdpSimplify";
9
8
  import { fitEasesFromVelocity } from "../utils/velocityEaseFitter";
@@ -294,9 +293,6 @@ export function useGestureCommit({
294
293
 
295
294
  // fallow-ignore-next-line complexity
296
295
  const handleToggleRecording = useCallback(() => {
297
- editLog("gesture", gestureStateRef.current === "recording" ? "stop" : "start", {
298
- id: domEditSessionRef.current.domEditSelection?.id,
299
- });
300
296
  if (gestureStateRef.current === "recording") {
301
297
  void stopAndCommitRecording();
302
298
  return;
@@ -8,7 +8,6 @@
8
8
  * from the rest of the editing orchestration.
9
9
  */
10
10
  import { useCallback } from "react";
11
- import { editLog } from "../utils/editDebugLog";
12
11
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
13
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
14
13
  import {
@@ -22,6 +21,7 @@ import {
22
21
  useSafeGsapCommitMutation,
23
22
  } from "./useSafeGsapCommitMutation";
24
23
  import type { CommitMutation } from "./gsapScriptCommitTypes";
24
+ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
25
25
 
26
26
  export interface UseGsapAwareEditingParams {
27
27
  domEditSelection: DomEditSelection | null;
@@ -97,7 +97,6 @@ export function useGsapAwareEditing({
97
97
  next: { x: number; y: number },
98
98
  modifiers?: { altKey?: boolean },
99
99
  ) => {
100
- editLog("manual-drag:move", { id: selection.id, next, altKey: modifiers?.altKey });
101
100
  if (gsapCommitMutation) {
102
101
  try {
103
102
  await tryGsapDragIntercept(
@@ -124,9 +123,35 @@ export function useGsapAwareEditing({
124
123
  ],
125
124
  );
126
125
 
126
+ // Multi-select (group) drag: route EACH element through the SAME GSAP intercept as
127
+ // a single drag, so every position is written as GSAP code (tl.set / keyframes /
128
+ // gsap.set) — NEVER the deprecated `--hf-studio-offset` CSS var, and GSAP-animated
129
+ // elements are no longer blocked in a group. No CSS fallback: with no GSAP
130
+ // composition there's nothing to write (a no-op, exactly like the single-drag path).
131
+ const handleGsapAwareGroupPathOffsetCommit = useCallback(
132
+ async (updates: DomEditGroupPathOffsetCommit[]) => {
133
+ if (!gsapCommitMutation) return;
134
+ for (const { selection, next } of updates) {
135
+ try {
136
+ await tryGsapDragIntercept(
137
+ selection,
138
+ next,
139
+ [],
140
+ previewIframeRef.current,
141
+ gsapCommitMutation,
142
+ makeFetchFallback(selection),
143
+ );
144
+ } catch (error) {
145
+ trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
146
+ throw error;
147
+ }
148
+ }
149
+ },
150
+ [gsapCommitMutation, previewIframeRef, makeFetchFallback, trackGsapInteractionFailure],
151
+ );
152
+
127
153
  const handleGsapAwareBoxSizeCommit = useCallback(
128
154
  async (selection: DomEditSelection, next: { width: number; height: number }) => {
129
- editLog("manual-drag:resize", { id: selection.id, next });
130
155
  if (gsapCommitMutation) {
131
156
  try {
132
157
  const handled = await tryGsapResizeIntercept(
@@ -157,7 +182,6 @@ export function useGsapAwareEditing({
157
182
 
158
183
  const handleGsapAwareRotationCommit = useCallback(
159
184
  async (selection: DomEditSelection, next: { angle: number }) => {
160
- editLog("manual-drag:rotate", { id: selection.id, next });
161
185
  if (gsapCommitMutation) {
162
186
  try {
163
187
  // Single source of truth for rotation too: tryGsapRotationIntercept handles
@@ -188,7 +212,7 @@ export function useGsapAwareEditing({
188
212
 
189
213
  // ── Animated property commit ──
190
214
 
191
- const commitAnimatedProperty = useAnimatedPropertyCommit({
215
+ const { commitAnimatedProperty, commitAnimatedProperties } = useAnimatedPropertyCommit({
192
216
  selectedGsapAnimations,
193
217
  gsapCommitMutation,
194
218
  addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
@@ -250,9 +274,11 @@ export function useGsapAwareEditing({
250
274
 
251
275
  return {
252
276
  handleGsapAwarePathOffsetCommit,
277
+ handleGsapAwareGroupPathOffsetCommit,
253
278
  handleGsapAwareBoxSizeCommit,
254
279
  handleGsapAwareRotationCommit,
255
280
  commitAnimatedProperty,
281
+ commitAnimatedProperties,
256
282
  handleSetArcPath,
257
283
  handleUpdateArcSegment,
258
284
  handleUnroll,
@@ -206,6 +206,7 @@ export function useGsapKeyframeOps({
206
206
  selection: DomEditSelection,
207
207
  animationId: string,
208
208
  resolvedFromValues?: Record<string, number | string>,
209
+ duration?: number,
209
210
  ) => {
210
211
  if (sdkSession && sdkDeps) {
211
212
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
@@ -221,7 +222,9 @@ export function useGsapKeyframeOps({
221
222
  }
222
223
  return commitMutation(
223
224
  selection,
224
- { type: "convert-to-keyframes", animationId, resolvedFromValues },
225
+ // `duration` only applies when the target is a static `set` (which has
226
+ // none) — it spans the converted keyframes across the element's clip.
227
+ { type: "convert-to-keyframes", animationId, resolvedFromValues, duration },
225
228
  { label: "Convert to keyframes" },
226
229
  );
227
230
  },