@hyperframes/studio 0.7.7 → 0.7.9

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 (43) hide show
  1. package/dist/assets/{index-B2YXvFxf.js → index-B7fv-WA3.js} +1 -1
  2. package/dist/assets/{index-BoASKOeE.js → index-C48wGs63.js} +1 -1
  3. package/dist/assets/{index-BeRh2hMe.js → index-e4xyxg7-.js} +187 -187
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +2715 -2061
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +5 -1
  11. package/src/components/editor/DomEditOverlay.tsx +4 -12
  12. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  13. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  14. package/src/components/editor/PropertyPanel.tsx +12 -0
  15. package/src/components/editor/Transform3DCube.tsx +313 -0
  16. package/src/components/editor/gsapAnimationConstants.ts +5 -0
  17. package/src/components/editor/manualOffsetDrag.test.ts +99 -0
  18. package/src/components/editor/manualOffsetDrag.ts +38 -7
  19. package/src/components/editor/marqueeCommit.ts +63 -20
  20. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  21. package/src/components/editor/propertyPanelHelpers.ts +43 -21
  22. package/src/components/editor/transform3dProjection.test.ts +99 -0
  23. package/src/components/editor/transform3dProjection.ts +172 -0
  24. package/src/components/sidebar/AssetsTab.tsx +23 -213
  25. package/src/components/sidebar/AudioRow.tsx +202 -0
  26. package/src/contexts/DomEditContext.tsx +4 -0
  27. package/src/hooks/gsapDragCommit.test.ts +9 -5
  28. package/src/hooks/gsapDragCommit.ts +28 -9
  29. package/src/hooks/gsapRuntimeKeyframes.ts +50 -5
  30. package/src/hooks/gsapRuntimePatch.ts +162 -35
  31. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  32. package/src/hooks/useDomEditCommits.ts +0 -2
  33. package/src/hooks/useDomEditSession.ts +4 -2
  34. package/src/hooks/useDomGeometryCommits.ts +3 -31
  35. package/src/hooks/useEnableKeyframes.test.ts +40 -0
  36. package/src/hooks/useEnableKeyframes.ts +9 -2
  37. package/src/hooks/useGsapAwareEditing.ts +31 -1
  38. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  39. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  40. package/src/hooks/useGsapTweenCache.ts +6 -4
  41. package/src/utils/marqueeGeometry.test.ts +15 -98
  42. package/src/utils/marqueeGeometry.ts +1 -158
  43. package/dist/assets/index-BSkUuN8g.css +0 -1
@@ -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,
@@ -452,7 +453,7 @@ export function useDomEditSession({
452
453
  handleDomAttributeLiveCommit,
453
454
  handleDomHtmlAttributeCommit,
454
455
  handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
455
- handleDomGroupPathOffsetCommit,
456
+ handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
456
457
  handleDomZIndexReorderCommit,
457
458
  handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit,
458
459
  handleDomRotationCommit: handleGsapAwareRotationCommit,
@@ -498,6 +499,7 @@ export function useDomEditSession({
498
499
  handleUpdateKeyframeEase,
499
500
  handleSetAllKeyframeEases,
500
501
  commitAnimatedProperty,
502
+ commitAnimatedProperties,
501
503
  handleSetArcPath,
502
504
  handleUpdateArcSegment,
503
505
  handleUnroll,
@@ -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,
@@ -1,10 +1,13 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
4
  import {
4
5
  animatedProps,
5
6
  buildExtendedKeyframes,
6
7
  isPlayheadWithinTween,
8
+ promoteSetToKeyframes,
7
9
  resolveNewTweenRange,
10
+ type EnableKeyframesSession,
8
11
  } from "./useEnableKeyframes";
9
12
 
10
13
  function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
@@ -128,3 +131,40 @@ describe("buildExtendedKeyframes", () => {
128
131
  expect(out.keyframes[1]!.percentage).toBeCloseTo(22.7, 1);
129
132
  });
130
133
  });
134
+
135
+ describe("promoteSetToKeyframes — auto endpoint", () => {
136
+ it("marks the 0% (held start) as `auto`, leaving the 100% (playhead) fixed", async () => {
137
+ let committed: Record<string, unknown> | undefined;
138
+ const session = {
139
+ commitMutation: async (mutation: Record<string, unknown>) => {
140
+ committed = mutation;
141
+ },
142
+ } as unknown as EnableKeyframesSession;
143
+ const sel = {
144
+ id: "card",
145
+ selector: "#card",
146
+ sourceFile: "index.html",
147
+ element: { isConnected: true } as unknown as HTMLElement,
148
+ } as unknown as DomEditSelection;
149
+ // readElementPosition reads gsap.getProperty off the iframe window.
150
+ const iframe = {
151
+ contentWindow: { gsap: { getProperty: () => -74 } },
152
+ } as unknown as HTMLIFrameElement;
153
+ const setAnim = anim({
154
+ id: "#card-set-0-position",
155
+ targetSelector: "#card",
156
+ method: "set",
157
+ global: true,
158
+ resolvedStart: 0,
159
+ properties: { x: -74, y: -469 },
160
+ });
161
+
162
+ await promoteSetToKeyframes(session, sel, setAnim, 1, iframe);
163
+
164
+ const kfs = committed?.keyframes as Array<{ percentage: number; auto?: boolean }>;
165
+ expect(committed?.type).toBe("replace-with-keyframes");
166
+ expect(kfs[0]).toMatchObject({ percentage: 0, auto: true });
167
+ expect(kfs[1].percentage).toBe(100);
168
+ expect(kfs[1].auto).toBeUndefined();
169
+ });
170
+ });
@@ -107,6 +107,7 @@ export function buildExtendedKeyframes(
107
107
  return { position: roundTo3(newStart), duration: newDuration, keyframes };
108
108
  }
109
109
 
110
+ // fallow-ignore-next-line complexity
110
111
  function readElementPosition(
111
112
  iframe: HTMLIFrameElement | null,
112
113
  sel: DomEditSelection,
@@ -238,8 +239,12 @@ async function applyKeyframeAtPlayhead(
238
239
  * two-stop tween from the set's time to the playhead — the held value at 0%, the
239
240
  * live value at 100% — giving the user something to animate. No-op if the playhead
240
241
  * is at or before the set.
242
+ *
243
+ * The 0% endpoint is the held start, which the user didn't choose — mark it `auto`
244
+ * so it tracks the nearest keyframe until edited directly. The 100% is the real
245
+ * keyframe being placed at the playhead, so it stays fixed.
241
246
  */
242
- async function promoteSetToKeyframes(
247
+ export async function promoteSetToKeyframes(
243
248
  session: EnableKeyframesSession,
244
249
  sel: DomEditSelection,
245
250
  setAnim: GsapAnimation,
@@ -267,6 +272,7 @@ async function promoteSetToKeyframes(
267
272
  {
268
273
  percentage: 0,
269
274
  properties: Object.keys(startPosition).length > 0 ? startPosition : endPosition,
275
+ auto: true,
270
276
  },
271
277
  { percentage: 100, properties: endPosition },
272
278
  ],
@@ -283,6 +289,7 @@ async function promoteSetToKeyframes(
283
289
  * the path, inserted at the matching segment so the curve is preserved. Outside the
284
290
  * range, extend the duration so the motion reaches the playhead.
285
291
  */
292
+ // fallow-ignore-next-line complexity
286
293
  async function applyArcWaypointAtPlayhead(
287
294
  session: EnableKeyframesSession,
288
295
  sel: DomEditSelection,
@@ -332,10 +339,10 @@ async function applyArcWaypointAtPlayhead(
332
339
  );
333
340
  }
334
341
 
335
- // fallow-ignore-next-line complexity
336
342
  export function useEnableKeyframes(
337
343
  sessionRef: React.RefObject<EnableKeyframesSession | undefined>,
338
344
  ) {
345
+ // fallow-ignore-next-line complexity
339
346
  return useCallback(async () => {
340
347
  const session = sessionRef.current;
341
348
  if (!session) return;
@@ -21,6 +21,7 @@ import {
21
21
  useSafeGsapCommitMutation,
22
22
  } from "./useSafeGsapCommitMutation";
23
23
  import type { CommitMutation } from "./gsapScriptCommitTypes";
24
+ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
24
25
 
25
26
  export interface UseGsapAwareEditingParams {
26
27
  domEditSelection: DomEditSelection | null;
@@ -122,6 +123,33 @@ export function useGsapAwareEditing({
122
123
  ],
123
124
  );
124
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
+
125
153
  const handleGsapAwareBoxSizeCommit = useCallback(
126
154
  async (selection: DomEditSelection, next: { width: number; height: number }) => {
127
155
  if (gsapCommitMutation) {
@@ -184,7 +212,7 @@ export function useGsapAwareEditing({
184
212
 
185
213
  // ── Animated property commit ──
186
214
 
187
- const commitAnimatedProperty = useAnimatedPropertyCommit({
215
+ const { commitAnimatedProperty, commitAnimatedProperties } = useAnimatedPropertyCommit({
188
216
  selectedGsapAnimations,
189
217
  gsapCommitMutation,
190
218
  addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
@@ -246,9 +274,11 @@ export function useGsapAwareEditing({
246
274
 
247
275
  return {
248
276
  handleGsapAwarePathOffsetCommit,
277
+ handleGsapAwareGroupPathOffsetCommit,
249
278
  handleGsapAwareBoxSizeCommit,
250
279
  handleGsapAwareRotationCommit,
251
280
  commitAnimatedProperty,
281
+ commitAnimatedProperties,
252
282
  handleSetArcPath,
253
283
  handleUpdateArcSegment,
254
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
  },