@hyperframes/studio 0.7.52 → 0.7.54

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-DKcziwpY.js → index-CMHYjEZ5.js} +1 -1
  2. package/dist/assets/index-pRhCpGPz.js +423 -0
  3. package/dist/assets/{index-B27HFK8R.js → index-uBY329wb.js} +1 -1
  4. package/dist/{chunk-BA66NM4L.js → chunk-SOTCF4DF.js} +4 -2
  5. package/dist/chunk-SOTCF4DF.js.map +1 -0
  6. package/dist/{domEditingLayers-H7LDFIJ7.js → domEditingLayers-2ECJK24D.js} +2 -2
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +669 -338
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/DomEditCropHandles.test.tsx +86 -0
  12. package/src/components/editor/DomEditCropHandles.tsx +96 -58
  13. package/src/components/editor/DomEditOverlay.test.ts +41 -0
  14. package/src/components/editor/DomEditOverlay.tsx +17 -1
  15. package/src/components/editor/domEditOverlayCrop.test.ts +137 -0
  16. package/src/components/editor/domEditOverlayCrop.ts +103 -7
  17. package/src/components/editor/domEditOverlayGestures.ts +35 -6
  18. package/src/components/editor/domEditOverlayStartGesture.ts +35 -2
  19. package/src/components/editor/domEditingDom.ts +1 -2
  20. package/src/components/editor/useDomEditOverlayGestures.ts +48 -7
  21. package/src/hooks/gsapResizeIntercept.test.ts +129 -0
  22. package/src/hooks/gsapResizeIntercept.ts +385 -0
  23. package/src/hooks/gsapRuntimeBridge.ts +3 -226
  24. package/src/hooks/gsapRuntimePatch.test.ts +63 -0
  25. package/src/hooks/gsapRuntimePatch.ts +35 -1
  26. package/src/hooks/gsapRuntimeReaders.test.ts +111 -0
  27. package/src/hooks/gsapRuntimeReaders.ts +39 -14
  28. package/src/hooks/useAnimatedPropertyCommit.test.tsx +66 -5
  29. package/src/hooks/useAnimatedPropertyCommit.ts +85 -17
  30. package/src/hooks/useGsapAwareEditing.ts +2 -5
  31. package/src/hooks/useGsapScriptCommits.test.tsx +86 -66
  32. package/src/hooks/useGsapScriptCommits.ts +27 -4
  33. package/src/utils/authoredOpacity.ts +35 -0
  34. package/src/utils/elementGsap.ts +31 -0
  35. package/src/utils/gsapSoftReload.test.ts +86 -2
  36. package/src/utils/gsapSoftReload.ts +69 -7
  37. package/dist/assets/index-v3DXednk.js +0 -423
  38. package/dist/chunk-BA66NM4L.js.map +0 -1
  39. /package/dist/{domEditingLayers-H7LDFIJ7.js.map → domEditingLayers-2ECJK24D.js.map} +0 -0
@@ -133,6 +133,69 @@ describe("patchRuntimeTweenInPlace — set tweens", () => {
133
133
  });
134
134
  });
135
135
 
136
+ describe("patchRuntimeTweenInPlace — authored-opacity capture guard", () => {
137
+ function makeStampedEl(id: string, stamped: string | null, inlineOpacity: string) {
138
+ const style = new Map<string, string>([["opacity", inlineOpacity]]);
139
+ return {
140
+ el: {
141
+ id,
142
+ style: {
143
+ setProperty: (k: string, v: string) => void style.set(k, v),
144
+ removeProperty: (k: string) => void style.delete(k),
145
+ },
146
+ getAttribute: (name: string) => (name === "data-hf-authored-opacity" ? stamped : null),
147
+ },
148
+ style,
149
+ };
150
+ }
151
+
152
+ it("restores the stamped authored opacity before an opacity-touching patch", () => {
153
+ // Runtime transient (grading hide / mid-flight tween) baked into inline style.
154
+ const { el, style } = makeStampedEl("box", "0.75", "0");
155
+ const setTween = makeTween(
156
+ { vars: { opacity: 0.2, duration: 0 }, targetIds: ["box"], duration: 0 },
157
+ el,
158
+ );
159
+ const { iframe } = fakeIframe(el, [setTween]);
160
+
161
+ const ok = patchRuntimeTweenInPlace(iframe, "#box", {
162
+ kind: "set",
163
+ props: { opacity: 0.5 },
164
+ });
165
+
166
+ expect(ok).toBe(true);
167
+ // The re-init must capture the authored 0.75, not the transient 0.
168
+ expect(style.get("opacity")).toBe("0.75");
169
+ expect(setTween.vars.opacity).toBe(0.5);
170
+ });
171
+
172
+ it("removes inline opacity when the stamp recorded no authored value", () => {
173
+ const { el, style } = makeStampedEl("box", "", "0");
174
+ const setTween = makeTween(
175
+ { vars: { opacity: 0.2, duration: 0 }, targetIds: ["box"], duration: 0 },
176
+ el,
177
+ );
178
+ const { iframe } = fakeIframe(el, [setTween]);
179
+
180
+ patchRuntimeTweenInPlace(iframe, "#box", { kind: "set", props: { opacity: 0.5 } });
181
+
182
+ expect(style.has("opacity")).toBe(false);
183
+ });
184
+
185
+ it("leaves inline opacity alone for a position-only patch", () => {
186
+ const { el, style } = makeStampedEl("box", "0.75", "0");
187
+ const setTween = makeTween(
188
+ { vars: { x: 0, y: 0, duration: 0 }, targetIds: ["box"], duration: 0 },
189
+ el,
190
+ );
191
+ const { iframe } = fakeIframe(el, [setTween]);
192
+
193
+ patchRuntimeTweenInPlace(iframe, "#box", { kind: "set", props: { x: 10, y: 20 } });
194
+
195
+ expect(style.get("opacity")).toBe("0");
196
+ });
197
+ });
198
+
136
199
  describe("patchRuntimeTweenInPlace — channel-aware set resolution", () => {
137
200
  it("patches the {x,y} set, not a co-located rotation-only set", () => {
138
201
  const el = { id: "dual" };
@@ -13,6 +13,7 @@
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 { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "../utils/authoredOpacity";
16
17
  import {
17
18
  resolveRuntimeTween,
18
19
  type RuntimeTween,
@@ -235,6 +236,34 @@ function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): vo
235
236
  player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0);
236
237
  }
237
238
 
239
+ /** Does this change touch the opacity channel (whose re-init reads inline style)? */
240
+ function changeTouchesOpacity(change: RuntimeTweenChange): boolean {
241
+ if (change.kind === "set" || change.kind === "global-set")
242
+ return change.props.opacity !== undefined;
243
+ if (change.kind === "keyframes") return change.keyframes.some((step) => "opacity" in step);
244
+ return "opacity" in change.props;
245
+ }
246
+
247
+ /**
248
+ * A tween re-initialization (invalidate, or kill+recreate for keyframe-rebuild)
249
+ * captures opacity from the element's CURRENT inline style — for a color-graded
250
+ * source (hidden with `opacity: 0 !important`) or a mid-flight tween that's a
251
+ * runtime transient, not the authored value, and the capture makes it permanent.
252
+ * Restore the runtime's parse-time authored capture (data-hf-authored-opacity)
253
+ * first; the re-seek after the patch re-renders the animated value anyway.
254
+ * Duck-typed (no instanceof): the targets live in the preview iframe's realm.
255
+ */
256
+ function restoreAuthoredOpacityForCapture(tween: RuntimeTween): void {
257
+ const targets = typeof tween.targets === "function" ? tween.targets() : [];
258
+ for (const target of targets ?? []) {
259
+ const el = target as HTMLElement | null;
260
+ if (!el?.style || typeof el.getAttribute !== "function") continue;
261
+ const authored = readStampedAuthoredOpacity(el);
262
+ if (authored === null) continue;
263
+ applyAuthoredInlineOpacity(el.style, authored);
264
+ }
265
+ }
266
+
238
267
  /** Apply `change` to the resolved tween. `true` if applied, `false` to soft-reload.
239
268
  * `global-set` is handled before this (no tween) and never reaches here. */
240
269
  function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean {
@@ -270,13 +299,18 @@ export function patchRuntimeTweenInPlace(
270
299
  if (!resolved) return false;
271
300
  const { tween, timeline } = resolved;
272
301
 
302
+ if (changeTouchesOpacity(change)) restoreAuthoredOpacityForCapture(tween);
273
303
  if (!applyChange(tween, change)) return false;
274
304
 
275
305
  // A rebuild already recreated the tween; set/keyframes mutate vars in place, so
276
306
  // invalidate to make GSAP re-read them on the next render. Either way, re-seek.
307
+ // Invalidate ONLY the edited tween — never the whole timeline. A timeline-wide
308
+ // invalidate re-initializes every from() tween against the CURRENT inline
309
+ // styles, and the color-grading engine hides its source elements with
310
+ // `opacity: 0 !important` — so every graded element's from(opacity) re-captures
311
+ // 0 as its end value and animates 0→0 forever (all graded elements vanish).
277
312
  if (change.kind !== "keyframe-rebuild") {
278
313
  tween.invalidate?.();
279
- timeline.invalidate?.();
280
314
  }
281
315
  seekToCurrent(iframe, timeline);
282
316
  return true;
@@ -0,0 +1,111 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
+ import {
5
+ COLOR_GRADING_SOURCE_HIDDEN_ATTR,
6
+ HF_COLOR_GRADING_CANVAS_ID_PREFIX,
7
+ } from "@hyperframes/core/color-grading";
8
+ import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
9
+
10
+ /**
11
+ * Regression: converting a property-group tween to keyframes resolves "current
12
+ * values" via readAllAnimatedProperties. Two ways that used to bake garbage
13
+ * into the composition file:
14
+ *
15
+ * 1. The group filter only pruned the tween's OWN properties — the baseline
16
+ * pass still captured every property ANY tween on the element touches, so
17
+ * a rotation commit carried `opacity` from the intro from() tween.
18
+ * 2. `gsap.getProperty(el, "opacity")` on a color-grading source reads the
19
+ * runtime hide (inline `opacity: 0 !important`), not the animated value —
20
+ * so the captured opacity was the transient 0, which then animated 0 → 0
21
+ * on the next full load and the element disappeared.
22
+ */
23
+
24
+ function fakeIframe(
25
+ el: Element,
26
+ opts: { gsapValues: Record<string, number>; otherTweenVars?: Record<string, number> },
27
+ ): HTMLIFrameElement {
28
+ const children = opts.otherTweenVars
29
+ ? [{ targets: () => [el], vars: { duration: 0.8, ...opts.otherTweenVars } }]
30
+ : [];
31
+ return {
32
+ contentWindow: {
33
+ __timelines: { main: { getChildren: () => children } },
34
+ gsap: { getProperty: (_el: Element, prop: string) => opts.gsapValues[prop] ?? 0 },
35
+ },
36
+ contentDocument: document,
37
+ } as unknown as HTMLIFrameElement;
38
+ }
39
+
40
+ function rotationSetAnim(): GsapAnimation {
41
+ return {
42
+ id: "#clip-set-0-rotation",
43
+ targetSelector: "#clip",
44
+ method: "set",
45
+ properties: { rotation: 0 },
46
+ } as unknown as GsapAnimation;
47
+ }
48
+
49
+ afterEach(() => {
50
+ document.body.innerHTML = "";
51
+ });
52
+
53
+ describe("readAllAnimatedProperties group filter", () => {
54
+ it("keeps other tweens' out-of-group properties out of a grouped resolve", () => {
55
+ const el = document.createElement("div");
56
+ el.id = "clip";
57
+ document.body.appendChild(el);
58
+ const iframe = fakeIframe(el, {
59
+ gsapValues: { rotation: -28.1, opacity: 0, rotationX: 52, rotationY: -47 },
60
+ otherTweenVars: { opacity: 0, rotationX: 52, rotationY: -47 },
61
+ });
62
+
63
+ const result = readAllAnimatedProperties(iframe, "#clip", rotationSetAnim(), "rotation");
64
+
65
+ expect(result).toEqual({ rotation: -28.1 });
66
+ });
67
+ });
68
+
69
+ describe("color-grading opacity truth", () => {
70
+ function gradedElement(): HTMLElement {
71
+ const el = document.createElement("img");
72
+ el.id = "clip";
73
+ el.setAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR, "");
74
+ const canvas = document.createElement("canvas");
75
+ canvas.id = `${HF_COLOR_GRADING_CANVAS_ID_PREFIX}clip`;
76
+ canvas.style.opacity = "0.98";
77
+ document.body.append(el, canvas);
78
+ return el;
79
+ }
80
+
81
+ it("resolves opacity from the grading canvas, not the runtime hide", () => {
82
+ const el = gradedElement();
83
+ const iframe = fakeIframe(el, { gsapValues: { opacity: 0 } });
84
+ const anim = {
85
+ id: "#clip-from-200-visual",
86
+ targetSelector: "#clip",
87
+ method: "from",
88
+ properties: { opacity: 0.5 },
89
+ } as unknown as GsapAnimation;
90
+
91
+ const result = readAllAnimatedProperties(iframe, "#clip", anim);
92
+
93
+ expect(result.opacity).toBe(0.98);
94
+ });
95
+
96
+ it("readGsapProperty takes the same detour", () => {
97
+ const el = gradedElement();
98
+ const iframe = fakeIframe(el, { gsapValues: { opacity: 0 } });
99
+
100
+ expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.98);
101
+ });
102
+
103
+ it("reads GSAP directly when the source is not grading-hidden", () => {
104
+ const el = document.createElement("div");
105
+ el.id = "clip";
106
+ document.body.appendChild(el);
107
+ const iframe = fakeIframe(el, { gsapValues: { opacity: 0.3 } });
108
+
109
+ expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.3);
110
+ });
111
+ });
@@ -3,9 +3,33 @@
3
3
  */
4
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
5
  import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser";
6
- import { getIframeGsap, queryIframeElement } from "./gsapShared";
6
+ import {
7
+ COLOR_GRADING_SOURCE_HIDDEN_ATTR,
8
+ HF_COLOR_GRADING_CANVAS_ID_PREFIX,
9
+ } from "@hyperframes/core/color-grading";
10
+ import { getIframeGsap, queryIframeElement, type IframeGsap } from "./gsapShared";
7
11
  import { roundTo3 } from "../utils/rounding";
8
12
 
13
+ /**
14
+ * The element's live value for `prop` as GSAP drives it. Opacity on a
15
+ * color-grading-hidden source needs a detour: the runtime hides the source
16
+ * with inline `opacity: 0 !important`, so computed opacity is the hide, not
17
+ * the animated value. The grading canvas mirrors the source's effective
18
+ * opacity every frame, so it is the truth for that one property — reading the
19
+ * raw 0 here is what bakes `opacity: 0` into committed keyframes.
20
+ */
21
+ function readLiveGsapValue(gsap: IframeGsap, el: Element, prop: string): number {
22
+ if (prop === "opacity" && el.getAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) != null && el.id) {
23
+ const canvas = el.ownerDocument.getElementById(HF_COLOR_GRADING_CANVAS_ID_PREFIX + el.id);
24
+ const win = el.ownerDocument.defaultView;
25
+ if (canvas && win) {
26
+ const val = Number(win.getComputedStyle(canvas).opacity);
27
+ if (Number.isFinite(val)) return val;
28
+ }
29
+ }
30
+ return Number(gsap.getProperty(el, prop));
31
+ }
32
+
9
33
  export function readGsapProperty(
10
34
  iframe: HTMLIFrameElement | null,
11
35
  selector: string | null,
@@ -17,7 +41,7 @@ export function readGsapProperty(
17
41
  const el = queryIframeElement(iframe, selector);
18
42
  if (!el) return null;
19
43
  try {
20
- const val = Number(gsap.getProperty(el, prop));
44
+ const val = readLiveGsapValue(gsap, el, prop);
21
45
  if (!Number.isFinite(val)) return null;
22
46
  return POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val);
23
47
  } catch {
@@ -77,15 +101,15 @@ export function readAllAnimatedProperties(
77
101
  for (const p of Object.keys(anim.properties)) propKeys.add(p);
78
102
  }
79
103
 
80
- // When a group filter is specified, only keep properties belonging to that group.
81
- if (group) {
82
- for (const p of propKeys) {
83
- if (classifyPropertyGroup(p) !== group) propKeys.delete(p);
84
- }
85
- }
104
+ // When a group filter is specified, only properties belonging to that group
105
+ // may enter the result — including the baseline passes below. The whole
106
+ // point of property-group tweens is that a rotation commit never carries
107
+ // opacity/rotationX/etc. captured from unrelated tweens on the element.
108
+ const inGroup = (p: string) => !group || classifyPropertyGroup(p) === group;
109
+ const groupedPropKeys = new Set([...propKeys].filter(inGroup));
86
110
 
87
- for (const prop of propKeys) {
88
- const val = Number(gsap.getProperty(el, prop));
111
+ for (const prop of groupedPropKeys) {
112
+ const val = readLiveGsapValue(gsap, el, prop);
89
113
  if (Number.isFinite(val)) {
90
114
  result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val);
91
115
  }
@@ -110,13 +134,13 @@ export function readAllAnimatedProperties(
110
134
  const vars = child.vars;
111
135
  if (!vars) continue;
112
136
  for (const k of Object.keys(vars)) {
113
- if (!GSAP_CONFIG_KEYS.has(k)) otherTweenProps.add(k);
137
+ if (!GSAP_CONFIG_KEYS.has(k) && inGroup(k)) otherTweenProps.add(k);
114
138
  }
115
139
  }
116
140
  }
117
141
  }
118
142
  } catch {}
119
- for (const p of propKeys) otherTweenProps.delete(p);
143
+ for (const p of groupedPropKeys) otherTweenProps.delete(p);
120
144
 
121
145
  // Tier 1: Transform + visual properties with universal CSS defaults.
122
146
  // Safe to compare against hardcoded values — these are always 0 or 1
@@ -148,11 +172,11 @@ export function readAllAnimatedProperties(
148
172
  // Collect all properties that ANY tween on this element explicitly targets.
149
173
  // Only capture baseline values for these — GSAP reports non-default values
150
174
  // (scaleZ=0, brightness=0) for untouched properties, polluting keyframes.
151
- const allTweenedProps = new Set([...propKeys, ...otherTweenProps]);
175
+ const allTweenedProps = new Set([...groupedPropKeys, ...otherTweenProps]);
152
176
  for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
153
177
  if (prop in result) continue;
154
178
  if (!allTweenedProps.has(prop)) continue;
155
- const val = Number(gsap.getProperty(el, prop));
179
+ const val = readLiveGsapValue(gsap, el, prop);
156
180
  if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) {
157
181
  result[prop] = roundTo3(val);
158
182
  }
@@ -184,6 +208,7 @@ export function readAllAnimatedProperties(
184
208
  } catch {}
185
209
  for (const prop of COMPUTED_BASELINE) {
186
210
  if (prop in result) continue;
211
+ if (!inGroup(prop)) continue;
187
212
  if (otherTweenProps.has(prop)) continue;
188
213
  const gsapVal = Number(gsap.getProperty(el, prop));
189
214
  if (!Number.isFinite(gsapVal)) continue;
@@ -39,15 +39,16 @@ type Commit = (
39
39
  ) => Promise<void>;
40
40
 
41
41
  /** Renders the hook and hands its commit function to the caller via a ref callback. */
42
- function renderCommitHook(
43
- mutations: Array<Record<string, unknown>>,
42
+ function renderHookWith(
43
+ animations: GsapAnimation[],
44
+ onMutation: (mutation: Record<string, unknown>, label: string) => void,
44
45
  onReady: (commit: Commit) => void,
45
46
  ) {
46
47
  function Harness() {
47
48
  const { commitAnimatedProperties } = useAnimatedPropertyCommit({
48
- selectedGsapAnimations: [keyframedAnim],
49
- gsapCommitMutation: async (_sel, mutation) => {
50
- mutations.push(mutation);
49
+ selectedGsapAnimations: animations,
50
+ gsapCommitMutation: async (_sel, mutation, options) => {
51
+ onMutation(mutation, options.label);
51
52
  },
52
53
  addGsapAnimation: vi.fn(),
53
54
  convertToKeyframes: vi.fn(),
@@ -66,6 +67,13 @@ function renderCommitHook(
66
67
  return root;
67
68
  }
68
69
 
70
+ function renderCommitHook(
71
+ mutations: Array<Record<string, unknown>>,
72
+ onReady: (commit: Commit) => void,
73
+ ) {
74
+ return renderHookWith([keyframedAnim], (mutation) => mutations.push(mutation), onReady);
75
+ }
76
+
69
77
  // Regression (#1808): a "3D transform" / design-panel property edit on an
70
78
  // element that already has a keyframed tween is the ACTUAL path a manual
71
79
  // canvas nudge exercises (not the raw drag intercept) — with auto-keyframe
@@ -103,3 +111,56 @@ describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", ()
103
111
  act(() => root.unmount());
104
112
  });
105
113
  });
114
+
115
+ // Regression: commitStaticSet picked the FIRST `set` for the selector with no
116
+ // group check — a panel W edit on a static element merged `width` into the
117
+ // POSITION set (`tl.set("#el",{x,y,width})`), a mixed-group set the split
118
+ // machinery exists to prevent, labeled "Set 3D transform" in undo history.
119
+ describe("commitStaticSet group routing", () => {
120
+ const positionSet = {
121
+ id: "#box-set-0-position",
122
+ targetSelector: "#box",
123
+ propertyGroup: "position",
124
+ method: "set",
125
+ properties: { x: 10, y: 20 },
126
+ } as unknown as GsapAnimation;
127
+
128
+ function renderStaticHook(
129
+ committed: Array<{ mutation: Record<string, unknown>; label: string }>,
130
+ onReady: (commit: Commit) => void,
131
+ ) {
132
+ return renderHookWith(
133
+ [positionSet],
134
+ (mutation, label) => committed.push({ mutation, label }),
135
+ onReady,
136
+ );
137
+ }
138
+
139
+ it("width edit creates a size set instead of contaminating the position set", async () => {
140
+ const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
141
+ let commit!: Commit;
142
+ renderStaticHook(committed, (c) => (commit = c));
143
+ await act(async () => {
144
+ await commit(selection, { width: 500 });
145
+ });
146
+ const updates = committed.filter((c) => c.mutation.type === "update-properties");
147
+ expect(updates).toHaveLength(0);
148
+ const adds = committed.filter((c) => c.mutation.type === "add");
149
+ expect(adds).toHaveLength(1);
150
+ expect(adds[0]!.mutation.properties).toEqual({ width: 500 });
151
+ expect(adds[0]!.label).toBe("Resize layer");
152
+ });
153
+
154
+ it("x edit updates the position set with a Move label", async () => {
155
+ const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
156
+ let commit!: Commit;
157
+ renderStaticHook(committed, (c) => (commit = c));
158
+ await act(async () => {
159
+ await commit(selection, { x: 400 });
160
+ });
161
+ const update = committed.find((c) => c.mutation.type === "update-properties");
162
+ expect(update).toBeDefined();
163
+ expect(update!.mutation.animationId).toBe("#box-set-0-position");
164
+ expect(update!.label).toBe("Move layer");
165
+ });
166
+ });
@@ -103,6 +103,22 @@ async function maybeAutoKeyframeSet(
103
103
 
104
104
  type Commit = NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>;
105
105
 
106
+ /** Undo-history label for a static-set commit, from the group it writes. */
107
+ const STATIC_SET_LABELS: Partial<Record<ReturnType<typeof classifyPropertyGroup>, string>> = {
108
+ position: "Move layer",
109
+ scale: "Resize layer",
110
+ size: "Resize layer",
111
+ rotation: "Rotate layer",
112
+ visual: "Set opacity",
113
+ other: "Set 3D transform",
114
+ };
115
+
116
+ function staticSetLabel(propEntries: [string, number | string][]): string {
117
+ const groups = new Set(propEntries.map(([k]) => classifyPropertyGroup(k)));
118
+ const only = groups.size === 1 ? [...groups][0] : undefined;
119
+ return (only && STATIC_SET_LABELS[only]) || "Set properties";
120
+ }
121
+
106
122
  /** Merge ALL props into the static `set` in ONE commit (value-only, instant), then
107
123
  * auto-keyframe. One mutation — a per-property loop would shift the set's
108
124
  * group-derived id mid-way (e.g. reset adding `scale` to a rotation set), 404-ing
@@ -133,7 +149,11 @@ async function commitSetProps(
133
149
  await commit(
134
150
  selection,
135
151
  { type: "update-properties", animationId: setAnim.id, properties },
136
- { label: "Set 3D transform", softReload: true, ...(instantPatch ? { instantPatch } : {}) },
152
+ {
153
+ label: staticSetLabel(propEntries),
154
+ softReload: true,
155
+ ...(instantPatch ? { instantPatch } : {}),
156
+ },
137
157
  );
138
158
  await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
139
159
  }
@@ -152,22 +172,70 @@ async function commitStaticSet(
152
172
  commit: Commit,
153
173
  ): Promise<void> {
154
174
  if (!selector) return;
155
- // Update an existing `set` in ONE batched commitNEVER a flat `to`/`from`. A
156
- // set's id is GROUP-derived, so a per-prop loop shifts it the instant a new-group
157
- // prop lands (e.g. `scale` onto a rotation set), 404-ing the next prop; commitSetProps
158
- // sends them together. A static element with no set gets a dedicated `set` carrying
159
- // ALL props in ONE `add`.
160
- const existingSet = animations.find((a) => a.method === "set" && a.targetSelector === selector);
161
- if (existingSet) {
162
- await commitSetProps(selection, existingSet, propEntries, selector, animations, commit);
163
- return;
175
+ // One commit per PROPERTY GROUP, each into a set that owns that groupnever a
176
+ // flat `to`/`from`, and never a foreign-group set (a width edit used to merge
177
+ // into the element's position set, producing a mixed set the split machinery
178
+ // exists to prevent). Within a group everything batches into ONE commit: a
179
+ // set's id is group-derived, so a per-prop loop would shift the id mid-way and
180
+ // 404 the next update.
181
+ const byGroup = new Map<string, [string, number | string][]>();
182
+ for (const entry of propEntries) {
183
+ const group = classifyPropertyGroup(entry[0]);
184
+ const batch = byGroup.get(group) ?? [];
185
+ batch.push(entry);
186
+ byGroup.set(group, batch);
187
+ }
188
+ const sets = animations.filter((a) => a.method === "set" && a.targetSelector === selector);
189
+ // Resolve every group's target BEFORE committing anything, and coalesce
190
+ // groups that land on the SAME set into one commit: the `sets` snapshot is
191
+ // captured once, so if two groups resolved to one legacy mixed set, a first
192
+ // commit could re-shape it server-side and leave the second chasing a stale
193
+ // id (404 on legacy pre-split files).
194
+ const byTargetSet = new Map<GsapAnimation, [string, number | string][]>();
195
+ const newSetBatches: [string, number | string][][] = [];
196
+ for (const [group, batch] of byGroup) {
197
+ const existingSet = findGroupOwningSet(sets, group);
198
+ if (existingSet) {
199
+ byTargetSet.set(existingSet, [...(byTargetSet.get(existingSet) ?? []), ...batch]);
200
+ } else {
201
+ newSetBatches.push(batch);
202
+ }
164
203
  }
165
- // Base `gsap.set` (off-timeline) a static hold with no 0% keyframe marker, so
166
- // adjusting a 3D transform on a non-keyframed element doesn't drop a keyframe on
167
- // the timeline (matches the manual-drag UX). The global-set instant patch applies
168
- // it straight to the element so the first edit shows with no soft-reload flash.
204
+ for (const [targetSet, batch] of byTargetSet) {
205
+ await commitSetProps(selection, targetSet, batch, selector, animations, commit);
206
+ }
207
+ // Fresh adds don't reshape existing sets, so their ids can't go stale.
208
+ for (const batch of newSetBatches) {
209
+ await addGlobalStaticSet(selection, batch, selector, commit);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * The set that owns a property group: one already dedicated to the group wins;
215
+ * else a mixed set that already carries a property of the group (merging
216
+ * same-group values there beats spawning a second writer for the channel).
217
+ */
218
+ function findGroupOwningSet(sets: GsapAnimation[], group: string): GsapAnimation | undefined {
219
+ return (
220
+ sets.find((a) => a.propertyGroup === group) ??
221
+ sets.find((a) => Object.keys(a.properties).some((k) => classifyPropertyGroup(k) === group))
222
+ );
223
+ }
224
+
225
+ /**
226
+ * Base `gsap.set` (off-timeline) — a static hold with no 0% keyframe marker, so
227
+ * adjusting a 3D transform on a non-keyframed element doesn't drop a keyframe on
228
+ * the timeline (matches the manual-drag UX). The global-set instant patch applies
229
+ * it straight to the element so the first edit shows with no soft-reload flash.
230
+ */
231
+ async function addGlobalStaticSet(
232
+ selection: DomEditSelection,
233
+ batch: [string, number | string][],
234
+ selector: string,
235
+ commit: Commit,
236
+ ): Promise<void> {
169
237
  const numericProps: SetPatchProps = {};
170
- for (const [k, v] of propEntries) {
238
+ for (const [k, v] of batch) {
171
239
  if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
172
240
  }
173
241
  await commit(
@@ -177,11 +245,11 @@ async function commitStaticSet(
177
245
  targetSelector: selector,
178
246
  method: "set",
179
247
  position: 0,
180
- properties: Object.fromEntries(propEntries),
248
+ properties: Object.fromEntries(batch),
181
249
  global: true,
182
250
  },
183
251
  {
184
- label: "Set 3D transform",
252
+ label: staticSetLabel(batch),
185
253
  softReload: true,
186
254
  ...(Object.keys(numericProps).length > 0
187
255
  ? {
@@ -10,11 +10,8 @@
10
10
  import { useCallback } from "react";
11
11
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
12
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
- import {
14
- tryGsapDragIntercept,
15
- tryGsapResizeIntercept,
16
- tryGsapRotationIntercept,
17
- } from "./gsapRuntimeBridge";
13
+ import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
14
+ import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
18
15
  import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
19
16
  import {
20
17
  useGsapSaveFailureTelemetry,