@hyperframes/studio 0.7.7 → 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 (39) hide show
  1. package/dist/assets/{index-B2YXvFxf.js → index--lOAjFJl.js} +1 -1
  2. package/dist/assets/{index-BeRh2hMe.js → index-BSyZKYmH.js} +194 -194
  3. package/dist/assets/{index-BoASKOeE.js → index-Dgeszckd.js} +1 -1
  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 +2699 -2052
  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/marqueeCommit.ts +63 -20
  18. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  19. package/src/components/editor/propertyPanelHelpers.ts +43 -21
  20. package/src/components/editor/transform3dProjection.test.ts +99 -0
  21. package/src/components/editor/transform3dProjection.ts +172 -0
  22. package/src/components/sidebar/AssetsTab.tsx +23 -213
  23. package/src/components/sidebar/AudioRow.tsx +202 -0
  24. package/src/contexts/DomEditContext.tsx +4 -0
  25. package/src/hooks/gsapDragCommit.test.ts +9 -5
  26. package/src/hooks/gsapDragCommit.ts +28 -9
  27. package/src/hooks/gsapRuntimeKeyframes.ts +50 -5
  28. package/src/hooks/gsapRuntimePatch.ts +162 -35
  29. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  30. package/src/hooks/useDomEditCommits.ts +0 -2
  31. package/src/hooks/useDomEditSession.ts +4 -2
  32. package/src/hooks/useDomGeometryCommits.ts +3 -31
  33. package/src/hooks/useGsapAwareEditing.ts +31 -1
  34. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  35. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  36. package/src/hooks/useGsapTweenCache.ts +6 -4
  37. package/src/utils/marqueeGeometry.test.ts +15 -98
  38. package/src/utils/marqueeGeometry.ts +1 -158
  39. package/dist/assets/index-BSkUuN8g.css +0 -1
@@ -13,13 +13,22 @@
13
13
  * "Which tween" is resolved by the same all-timelines scan `readRuntimeKeyframes`
14
14
  * uses (`resolveRuntimeTween`), so read and write agree on the target.
15
15
  */
16
- import { resolveRuntimeTween, type RuntimeTween } from "./gsapRuntimeKeyframes";
16
+ import {
17
+ resolveRuntimeTween,
18
+ type RuntimeTween,
19
+ type RuntimeTimeline,
20
+ } from "./gsapRuntimeKeyframes";
17
21
 
18
22
  /** Value-only channels a `tl.set(...)` patch may touch. */
19
23
  export interface SetPatchProps {
20
24
  x?: number;
21
25
  y?: number;
22
26
  rotation?: number;
27
+ rotationX?: number;
28
+ rotationY?: number;
29
+ rotationZ?: number;
30
+ z?: number;
31
+ transformPerspective?: number;
23
32
  scaleX?: number;
24
33
  scaleY?: number;
25
34
  scale?: number;
@@ -31,12 +40,28 @@ export type KeyframeStep = Record<string, number>;
31
40
 
32
41
  export type RuntimeTweenChange =
33
42
  | { kind: "set"; props: SetPatchProps }
34
- | { kind: "keyframes"; keyframes: KeyframeStep[] };
43
+ | { kind: "keyframes"; keyframes: KeyframeStep[] }
44
+ // Edit ONE step (at `pct`) of an object-form keyframe tween — the form the studio
45
+ // writes (`keyframes: { "0%": {...} }`). GSAP pre-compiles those into sub-tweens
46
+ // and won't re-read `vars.keyframes` on `invalidate()`, so this REBUILDS the tween
47
+ // (kill + recreate at the same position) instead of mutating it. Lets a design-panel
48
+ // keyframe edit show instantly rather than soft-reloading the iframe (a flash).
49
+ | { kind: "keyframe-rebuild"; pct: number; props: KeyframeStep }
50
+ // Apply a base `gsap.set` value to the element directly (`gsap.set(el, props)`).
51
+ // A base set lives OFF the timeline, so there's no runtime tween to patch — but
52
+ // the element is static on these channels, so setting them immediately reflects
53
+ // the edit with no soft reload (no flash) and leaves no keyframe marker.
54
+ | { kind: "global-set"; props: SetPatchProps };
35
55
 
36
56
  const SET_CHANNELS: Array<keyof SetPatchProps> = [
37
57
  "x",
38
58
  "y",
39
59
  "rotation",
60
+ "rotationX",
61
+ "rotationY",
62
+ "rotationZ",
63
+ "z",
64
+ "transformPerspective",
40
65
  "scaleX",
41
66
  "scaleY",
42
67
  "scale",
@@ -45,8 +70,42 @@ const SET_CHANNELS: Array<keyof SetPatchProps> = [
45
70
 
46
71
  type IframeWindow = Window & {
47
72
  __player?: { getTime?: () => number; seek?: (t: number) => void };
73
+ gsap?: { set?: (target: Element, vars: Record<string, number>) => void };
48
74
  };
49
75
 
76
+ /**
77
+ * Apply a base `gsap.set` value to the element in the live runtime. Returns `true`
78
+ * if applied. Used for off-timeline static holds (position / 3D transform) — there's
79
+ * no tween to patch, so we set the channels directly. Safe because the element is
80
+ * static on these channels (the caller only uses this for non-animated values).
81
+ */
82
+ /** The props as finite numbers, or null if any value is non-finite / none present. */
83
+ function finiteNumericProps(props: SetPatchProps): Record<string, number> | null {
84
+ const numeric: Record<string, number> = {};
85
+ for (const [k, v] of Object.entries(props)) {
86
+ if (typeof v !== "number" || !Number.isFinite(v)) return null;
87
+ numeric[k] = v;
88
+ }
89
+ return Object.keys(numeric).length > 0 ? numeric : null;
90
+ }
91
+
92
+ function applyGlobalSet(
93
+ iframe: HTMLIFrameElement,
94
+ selector: string,
95
+ props: SetPatchProps,
96
+ ): boolean {
97
+ try {
98
+ const gsapLib = (iframe.contentWindow as IframeWindow | null)?.gsap;
99
+ const el = iframe.contentDocument?.querySelector(selector) ?? null;
100
+ const numeric = finiteNumericProps(props);
101
+ if (!gsapLib?.set || !el || !numeric) return false;
102
+ gsapLib.set(el, numeric);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
50
109
  function playerOf(iframe: HTMLIFrameElement): IframeWindow["__player"] | null {
51
110
  try {
52
111
  return (iframe.contentWindow as IframeWindow | null)?.__player ?? null;
@@ -100,10 +159,95 @@ function patchKeyframes(tween: RuntimeTween, keyframes: KeyframeStep[]): boolean
100
159
  return true;
101
160
  }
102
161
 
162
+ /** The object-form keyframes map with `props` merged into the step at `pct`. */
163
+ function mergeKeyframeStep(
164
+ map: Record<string, Record<string, number>>,
165
+ pct: number,
166
+ props: KeyframeStep,
167
+ ): Record<string, Record<string, number>> {
168
+ const next: Record<string, Record<string, number>> = {};
169
+ for (const [k, step] of Object.entries(map)) next[k] = { ...step };
170
+ // Match the existing percentage key numerically ("50%" ≡ pct 50), else add one.
171
+ let key: string | null = null;
172
+ for (const k of Object.keys(next)) {
173
+ const n = parseFloat(k);
174
+ if (Number.isFinite(n) && Math.abs(n - pct) < 0.05) {
175
+ key = k;
176
+ break;
177
+ }
178
+ }
179
+ if (key === null) key = `${pct}%`;
180
+ next[key] = { ...(next[key] ?? {}), ...props };
181
+ return next;
182
+ }
183
+
184
+ /**
185
+ * Rebuild an object-form keyframe tween with `props` merged into the step at `pct`,
186
+ * in place: kill the old tween and recreate it on the SAME parent timeline at the
187
+ * SAME position, with all other vars (duration, ease, repeat, …) preserved. This
188
+ * is the only way to reflect an object-form keyframe edit live — GSAP compiles
189
+ * those keyframes into sub-tweens at creation and ignores later `vars.keyframes`
190
+ * mutations. Declines (→ caller soft-reloads) for array-form, motionPath arcs,
191
+ * non-finite/dynamic values, or a tween whose parent/targets can't be resolved.
192
+ */
193
+ // fallow-ignore-next-line complexity
194
+ function rebuildKeyframeTween(tween: RuntimeTween, pct: number, props: KeyframeStep): boolean {
195
+ const vars = tween.vars;
196
+ if (!vars || "motionPath" in vars) return false;
197
+ const kf = vars.keyframes;
198
+ if (!kf || typeof kf !== "object" || Array.isArray(kf)) return false;
199
+ for (const v of Object.values(props)) {
200
+ if (typeof v !== "number" || !Number.isFinite(v)) return false;
201
+ }
202
+ const parent = tween.parent;
203
+ const targets = tween.targets?.();
204
+ if (!parent?.to || !targets || targets.length === 0) return false;
205
+ if (typeof tween.startTime !== "function" || typeof tween.kill !== "function") return false;
206
+
207
+ const next = mergeKeyframeStep(kf as Record<string, Record<string, number>>, pct, props);
208
+ const newVars = { ...vars, keyframes: next };
209
+ const position = tween.startTime();
210
+ tween.kill();
211
+ parent.to(targets, newVars, position);
212
+ return true;
213
+ }
214
+
215
+ /** The channels a change writes, for the resolver to disambiguate co-located tweens. */
216
+ function changeChannels(change: RuntimeTweenChange): string[] | undefined {
217
+ if (change.kind === "set") {
218
+ return Object.keys(change.props).filter(
219
+ (k) => change.props[k as keyof SetPatchProps] !== undefined,
220
+ );
221
+ }
222
+ if (change.kind === "keyframe-rebuild") return Object.keys(change.props);
223
+ return undefined;
224
+ }
225
+
226
+ /** Re-render the timeline at the current playhead after an in-place edit. */
227
+ function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): void {
228
+ const player = playerOf(iframe);
229
+ const currentTime =
230
+ typeof player?.getTime === "function"
231
+ ? player.getTime()
232
+ : typeof timeline.time === "function"
233
+ ? timeline.time()
234
+ : 0;
235
+ player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0);
236
+ }
237
+
238
+ /** Apply `change` to the resolved tween. `true` if applied, `false` to soft-reload.
239
+ * `global-set` is handled before this (no tween) and never reaches here. */
240
+ function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean {
241
+ if (change.kind === "set") return patchSet(tween, change.props);
242
+ if (change.kind === "keyframes") return patchKeyframes(tween, change.keyframes);
243
+ if (change.kind === "keyframe-rebuild")
244
+ return rebuildKeyframeTween(tween, change.pct, change.props);
245
+ return false;
246
+ }
247
+
103
248
  /**
104
- * Update one tween's values in `window.__timelines` in place + re-seek to the
105
- * current playhead. Returns `true` on a confident patch, `false` otherwise
106
- * (caller falls back to a soft reload).
249
+ * Edit one tween in `window.__timelines` in place + re-seek to the current playhead.
250
+ * Returns `true` on a confident patch, `false` otherwise (caller soft-reloads).
107
251
  */
108
252
  export function patchRuntimeTweenInPlace(
109
253
  iframe: HTMLIFrameElement | null,
@@ -112,46 +256,29 @@ export function patchRuntimeTweenInPlace(
112
256
  compositionId?: string,
113
257
  ): boolean {
114
258
  if (!iframe) return false;
259
+ // A base `gsap.set` has no timeline tween to resolve — apply the value straight
260
+ // to the element so the edit shows instantly (no soft reload, no flash).
261
+ if (change.kind === "global-set") return applyGlobalSet(iframe, selector, change.props);
115
262
  try {
116
- // For a `set` patch, hand the resolver the channels actually being written so
117
- // it picks the set whose vars carry them — an element can have separate
118
- // {x,y} and {rotation} sets, and a position patch must not corrupt the
119
- // rotation set (channel-blind resolution would return the first match).
120
- const channels =
121
- change.kind === "set"
122
- ? Object.keys(change.props).filter(
123
- (k) => change.props[k as keyof SetPatchProps] !== undefined,
124
- )
125
- : undefined;
126
263
  const resolved = resolveRuntimeTween(
127
264
  iframe,
128
265
  selector,
129
266
  change.kind === "set" ? "set" : "keyframe",
130
267
  compositionId,
131
- channels,
268
+ changeChannels(change),
132
269
  );
133
270
  if (!resolved) return false;
134
271
  const { tween, timeline } = resolved;
135
272
 
136
- const applied =
137
- change.kind === "set"
138
- ? patchSet(tween, change.props)
139
- : patchKeyframes(tween, change.keyframes);
140
- if (!applied) return false;
141
-
142
- // Recompute the tween (and its timeline) from the new vars, then re-render at
143
- // the current playhead. `invalidate()` makes GSAP re-read vars on the next render.
144
- tween.invalidate?.();
145
- timeline.invalidate?.();
146
-
147
- const player = playerOf(iframe);
148
- const currentTime =
149
- typeof player?.getTime === "function"
150
- ? player.getTime()
151
- : typeof timeline.time === "function"
152
- ? timeline.time()
153
- : 0;
154
- player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0);
273
+ if (!applyChange(tween, change)) return false;
274
+
275
+ // A rebuild already recreated the tween; set/keyframes mutate vars in place, so
276
+ // invalidate to make GSAP re-read them on the next render. Either way, re-seek.
277
+ if (change.kind !== "keyframe-rebuild") {
278
+ tween.invalidate?.();
279
+ timeline.invalidate?.();
280
+ }
281
+ seekToCurrent(iframe, timeline);
155
282
  return true;
156
283
  } catch {
157
284
  return false;
@@ -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,