@hyperframes/studio 0.7.99 → 0.7.100

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.
@@ -149,7 +149,20 @@ export async function tryGsapResizeIntercept(
149
149
  if (!anim || isInstantHold(anim)) {
150
150
  const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
151
151
  if (!sel) return { status: "blocked", reason: "no-selector" };
152
- const sizeSet = anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element);
152
+ // A scale hold is not a size hold.
153
+ //
154
+ // `anim` is the tween resolved for THIS resize's group, and for a
155
+ // scale-driven element that is the one carrying `scale`. Handing it to the
156
+ // size commit wrote `width` and `height` into it, leaving one tween that
157
+ // spans two property groups — which the parser then classifies as neither,
158
+ // so it loses its group suffix and its id along with it. Every later edit
159
+ // of that element looked for a scale tween and a size tween, found no
160
+ // group at all, and the element became uneditable: "animation not found".
161
+ // Size goes to a size hold of its own, and the scale hold is left alone.
162
+ const sizeSet =
163
+ resizeGroup === "size"
164
+ ? (anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element))
165
+ : findSizeSetAnimation(workingAnimations, sel, selection.element);
153
166
 
154
167
  // If the element is animated (has a real tween, not just a static size
155
168
  // hold), keyframe the size at the playhead so other keyframes keep theirs —
@@ -239,7 +252,14 @@ export async function tryGsapResizeIntercept(
239
252
  // and the longhands win: the resize commits correctly and then does
240
253
  // nothing, and the element snaps back to its old size on release. The
241
254
  // tween never mixes the two forms in either direction.
242
- nonUniformScale = Math.abs(newScaleX - newScaleY) > 0.01;
255
+ //
256
+ // "Agree" is measured in PIXELS, not in scale. A fixed 0.01 of scale is
257
+ // invisible on a 40px box and two pixels of height on a 408px one, so a
258
+ // free drag whose axes happened to land within it silently gave back a box
259
+ // shorter than the one dropped. The question is only ever whether using one
260
+ // value for both axes would move an edge, so ask that.
261
+ const uniformDrift = Math.abs(newScaleX - newScaleY) * cssH;
262
+ nonUniformScale = uniformDrift > 0.5;
243
263
  useScaleLonghands = nonUniformScale || tweenUsesScaleLonghands(anim);
244
264
  resizeProps = useScaleLonghands
245
265
  ? { scaleX: newScaleX, scaleY: newScaleY }
@@ -290,10 +310,13 @@ export async function tryGsapResizeIntercept(
290
310
  // ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
291
311
  // approximate rather than corner-exact.
292
312
  // fallow-ignore-next-line complexity
293
- const finalizeScaleResizeCommit = async () => {
294
- if (!scaleDraftEl) return;
313
+ const finalizeScaleResizeCommit = async (): Promise<boolean> => {
314
+ // Only the scale route captures the element, so a null draft means this
315
+ // resize took the size route and never moved anything: the drop point is
316
+ // the drag's to settle, not ours.
317
+ if (!scaleDraftEl) return false;
295
318
  clearStudioBoxSize(scaleDraftEl);
296
- if (!scaleDraftDropPoint || !selector) return;
319
+ if (!scaleDraftDropPoint || !selector) return false;
297
320
  // Put the committed scale on the live element before measuring.
298
321
  //
299
322
  // This step reads where the commit lands the box and shifts the position
@@ -331,10 +354,12 @@ export async function tryGsapResizeIntercept(
331
354
  setElementGsapPosition(scaleDraftEl, base.x, base.y);
332
355
  const post = scaleDraftEl.getBoundingClientRect();
333
356
  const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
334
- if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return;
357
+ if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return false;
335
358
  if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
336
359
  logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
337
- return;
360
+ // Settled, with nothing to write. Still ours: forwarding the drag offset
361
+ // on top would move the box off the point it is already sitting on.
362
+ return true;
338
363
  }
339
364
  // The ONE corrected position — rounded once so the live runtime and the
340
365
  // persisted file agree exactly (commitStaticGsapPosition composes the same
@@ -385,13 +410,14 @@ export async function tryGsapResizeIntercept(
385
410
  commitMutation,
386
411
  fetchAnimations: fetchFallbackAnimations,
387
412
  });
388
- return;
413
+ return true;
389
414
  }
390
415
  const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
391
416
  await commitStaticGsapPosition(selection, delta, base, selector, existingSet, {
392
417
  commitMutation,
393
418
  fetchAnimations: fetchFallbackAnimations,
394
419
  });
420
+ return true;
395
421
  };
396
422
 
397
423
  // With auto-keyframe off (#1808), `anim` is already a real (non-"set")
@@ -408,8 +434,7 @@ export async function tryGsapResizeIntercept(
408
434
  { commitMutation, fetchAnimations: fetchFallbackAnimations },
409
435
  "Resize animation",
410
436
  );
411
- await finalizeScaleResizeCommit();
412
- return { status: "persisted" };
437
+ return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
413
438
  }
414
439
 
415
440
  const ct = usePlayerStore.getState().currentTime;
@@ -520,8 +545,7 @@ export async function tryGsapResizeIntercept(
520
545
  softReload: true,
521
546
  },
522
547
  );
523
- await finalizeScaleResizeCommit();
524
- return { status: "persisted" };
548
+ return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
525
549
  }
526
550
 
527
551
  const SIZE_PROPS = new Set(["width", "height"]);
@@ -542,8 +566,7 @@ export async function tryGsapResizeIntercept(
542
566
  },
543
567
  { label: `Resize (keyframe ${pct}%)`, softReload: true },
544
568
  );
545
- await finalizeScaleResizeCommit();
546
- return { status: "persisted" };
569
+ return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
547
570
  }
548
571
 
549
572
  // ── Rotation intercept ────────────────────────────────────────────────────
@@ -0,0 +1,168 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * Resizing `#card` in the shipped playground fails with "animation not found".
4
+ *
5
+ * The element carries five tweens, all at position 0 and all duration 0, and
6
+ * one of them mixes `scale` with `width`/`height`. That spans two property
7
+ * groups, so the parser gives it no group at all and the bare id `#card-to-0` —
8
+ * which means the resize finds neither a scale tween nor a size tween and has
9
+ * to split the mixed one apart before it can commit.
10
+ *
11
+ * This drives the real intercept against that exact set, with a server stand-in
12
+ * that answers the way the real one does: an id it cannot find is a 404. Any id
13
+ * the intercept sends that is not in the list it was last handed reproduces the
14
+ * failure.
15
+ */
16
+ import { afterEach, expect, it, vi } from "vitest";
17
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
18
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
19
+ import { usePlayerStore } from "../player/store/playerStore";
20
+ import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
25
+ document.body.innerHTML = "";
26
+ });
27
+
28
+ function hold(
29
+ id: string,
30
+ group: string | undefined,
31
+ properties: Record<string, number>,
32
+ ): GsapAnimation {
33
+ return {
34
+ id,
35
+ targetSelector: "#card",
36
+ propertyGroup: group,
37
+ method: "to",
38
+ properties,
39
+ position: 0,
40
+ resolvedStart: 0,
41
+ duration: 0,
42
+ extras: { immediateRender: "__raw:true" },
43
+ } as unknown as GsapAnimation;
44
+ }
45
+
46
+ /** `#card` as the playground actually holds it. */
47
+ function cardAnimations(): GsapAnimation[] {
48
+ return [
49
+ hold("#card-to-0-other", "other", { rotationY: -540, rotationX: 720, _auto: 0 }),
50
+ hold("#card-to-0-rotation", "rotation", { rotation: 720 }),
51
+ hold("#card-to-0", undefined, { scale: 1.2, width: 326, height: 213 }),
52
+ hold("#card-to-0-position", "position", { x: -1180, y: 128 }),
53
+ hold("#card-to-0-other-2", "other", { z: 50 }),
54
+ ];
55
+ }
56
+
57
+ it("never sends an animation id the server does not have", async () => {
58
+ const el = document.createElement("div");
59
+ el.id = "card";
60
+ el.setAttribute("data-hf-studio-original-box-width", "326");
61
+ el.setAttribute("data-hf-studio-original-box-height", "213");
62
+ document.body.append(el);
63
+
64
+ // The server's view of the file, and its 404.
65
+ let current = cardAnimations();
66
+ const rejected: string[] = [];
67
+ const sent: string[] = [];
68
+ const commitMutation = vi.fn(async (_selection: unknown, mutation: Record<string, unknown>) => {
69
+ const animationId = mutation.animationId as string | undefined;
70
+ if (animationId) {
71
+ sent.push(`${String(mutation.type)}:${animationId}`);
72
+ if (!current.some((a) => a.id === animationId)) {
73
+ rejected.push(animationId);
74
+ throw new Error("animation not found");
75
+ }
76
+ }
77
+ // Splitting the mixed tween is what the real server does with it.
78
+ if (mutation.type === "split-into-property-groups") {
79
+ current = [
80
+ hold("#card-to-0-other", "other", { rotationY: -540, rotationX: 720, _auto: 0 }),
81
+ hold("#card-to-0-rotation", "rotation", { rotation: 720 }),
82
+ hold("#card-to-0-position", "position", { x: -1180, y: 128 }),
83
+ hold("#card-to-0-other-2", "other", { z: 50 }),
84
+ hold("#card-to-0-scale", "scale", { scale: 1.2 }),
85
+ hold("#card-to-0-size", "size", { width: 326, height: 213 }),
86
+ ];
87
+ }
88
+ });
89
+
90
+ const selection = { id: "card", selector: "#card", element: el } as DomEditSelection;
91
+ await tryGsapResizeIntercept(
92
+ selection,
93
+ { width: 500, height: 320 },
94
+ cardAnimations(),
95
+ null,
96
+ commitMutation as never,
97
+ async () => current,
98
+ );
99
+
100
+ expect(rejected).toEqual([]);
101
+ expect(sent).not.toHaveLength(0);
102
+ });
103
+
104
+ /**
105
+ * How `#card` got into that state: a resize wrote `width`/`height` into the
106
+ * tween that carried `scale`.
107
+ *
108
+ * One tween spanning two property groups is classified as neither, so it loses
109
+ * its group suffix and its id with it — and every later edit of the element
110
+ * looks for a scale tween and a size tween, finds no group at all, and fails.
111
+ */
112
+ it("does not write size into the tween that carries scale", async () => {
113
+ const el = document.createElement("div");
114
+ el.id = "card";
115
+ el.setAttribute("data-hf-studio-original-box-width", "630");
116
+ el.setAttribute("data-hf-studio-original-box-height", "408");
117
+ document.body.append(el);
118
+
119
+ // The element before the damage: one instant scale hold, nothing else.
120
+ const scaleHold = hold("#card-to-0-scale", "scale", { scale: 1.2 });
121
+ const commitMutation = vi.fn();
122
+
123
+ await tryGsapResizeIntercept(
124
+ { id: "card", selector: "#card", element: el } as DomEditSelection,
125
+ { width: 326, height: 213 },
126
+ [scaleHold],
127
+ null,
128
+ commitMutation as never,
129
+ async () => [scaleHold],
130
+ );
131
+
132
+ const intoScaleHold = commitMutation.mock.calls
133
+ .map((call) => call[1] as { animationId?: string; properties?: Record<string, number> })
134
+ .filter((mutation) => mutation.animationId === "#card-to-0-scale")
135
+ .flatMap((mutation) => Object.keys(mutation.properties ?? {}));
136
+
137
+ expect(intoScaleHold).not.toContain("width");
138
+ expect(intoScaleHold).not.toContain("height");
139
+ });
140
+
141
+ /**
142
+ * Whether the caller must persist the drag offset is the resize's answer to
143
+ * give, not something to infer from the element's tweens.
144
+ *
145
+ * An element whose scale is an instant hold HAS a scale-group tween and still
146
+ * commits width/height. Guessing from the tweens withheld an offset nobody had
147
+ * written, and the element snapped back to its authored position on every drag.
148
+ */
149
+ it("leaves the drag offset to the caller when it commits size, not scale", async () => {
150
+ const el = document.createElement("div");
151
+ el.id = "card";
152
+ el.setAttribute("data-hf-studio-original-box-width", "630");
153
+ el.setAttribute("data-hf-studio-original-box-height", "408");
154
+ document.body.append(el);
155
+
156
+ const scaleHold = hold("#card-to-0-scale", "scale", { scale: 1.2 });
157
+ const outcome = await tryGsapResizeIntercept(
158
+ { id: "card", selector: "#card", element: el } as DomEditSelection,
159
+ { width: 326, height: 213 },
160
+ [scaleHold],
161
+ null,
162
+ vi.fn() as never,
163
+ async () => [scaleHold],
164
+ );
165
+
166
+ expect(outcome.status).toBe("persisted");
167
+ expect(outcome.status === "persisted" && outcome.ownsDragOffset).not.toBe(true);
168
+ });
@@ -0,0 +1,274 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * Every shape of animated element a composition can hand the resize, swept.
4
+ *
5
+ * Both faults this branch fixes were found one composition at a time, which is
6
+ * a bad way to find the third. So this drives the real intercept across the
7
+ * cross-product of what an element's tweens can look like and holds every run
8
+ * to the two rules that were broken:
9
+ *
10
+ * 1. Never address an animation the source does not have. Sending a stale id
11
+ * is what "animation not found" is, and it leaves the element unsavable.
12
+ * 2. Never leave a tween spanning two property groups. The parser classifies
13
+ * such a tween as neither, so it loses its group suffix and its id along
14
+ * with it, and every later edit has nothing to address.
15
+ *
16
+ * The server stand-in answers the way the real one does — an id it cannot find
17
+ * is a rejection — and applies what it is told, so a run that corrupts the
18
+ * animation list is caught by the next mutation in the same run rather than by
19
+ * a person noticing weeks later.
20
+ */
21
+ import { afterEach, expect, it, vi } from "vitest";
22
+ import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
23
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
24
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
25
+ import { usePlayerStore } from "../player/store/playerStore";
26
+ import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
27
+
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
31
+ document.body.innerHTML = "";
32
+ });
33
+
34
+ type Props = Record<string, number>;
35
+
36
+ function tween(id: string, properties: Props, duration: number): GsapAnimation {
37
+ return {
38
+ id,
39
+ targetSelector: "#el",
40
+ propertyGroup: classifyTweenPropertyGroup(properties),
41
+ method: "to",
42
+ properties,
43
+ position: 0,
44
+ resolvedStart: 0,
45
+ duration,
46
+ ...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
47
+ } as unknown as GsapAnimation;
48
+ }
49
+
50
+ /** The dimensions an element's animations actually vary across. */
51
+ const SCALE = {
52
+ none: null,
53
+ "instant hold": () => tween("#el-scale", { scale: 1.2 }, 0),
54
+ tween: () => tween("#el-scale", { scale: 1.2 }, 2),
55
+ longhands: () => tween("#el-scale", { scaleX: 1.2, scaleY: 1.1 }, 2),
56
+ } as const;
57
+ const SIZE = {
58
+ none: null,
59
+ "instant hold": () => tween("#el-size", { width: 300, height: 200 }, 0),
60
+ tween: () => tween("#el-size", { width: 300, height: 200 }, 2),
61
+ } as const;
62
+ const POSITION = {
63
+ none: null,
64
+ "static hold": () => tween("#el-position", { x: 40, y: 60 }, 0),
65
+ tween: () => tween("#el-position", { x: 40, y: 60 }, 2),
66
+ } as const;
67
+ const EXTRA = {
68
+ none: null,
69
+ // What a 3D card carries, and the shape that produced two same-group ids.
70
+ "3d and rotation": () => [
71
+ tween("#el-other", { rotationY: -540, rotationX: 720, _auto: 0 }, 0),
72
+ tween("#el-rotation", { rotation: 720 }, 0),
73
+ tween("#el-other-2", { z: 50 }, 0),
74
+ ],
75
+ // A tween that already spans two groups, which the resize has to split.
76
+ "a mixed tween": () => [tween("#el-mixed", { scale: 1.2, width: 300, height: 200 }, 0)],
77
+ } as const;
78
+
79
+ interface Recorded {
80
+ rejected: string[];
81
+ corrupted: string[];
82
+ }
83
+
84
+ /**
85
+ * The source, as the server sees it: a list of animations, a rejection for an
86
+ * id that is not in it, and the effect of each mutation applied.
87
+ */
88
+ function fakeSource(initial: GsapAnimation[]) {
89
+ let current = initial;
90
+ const recorded: Recorded = { rejected: [], corrupted: [] };
91
+
92
+ const mergeInto = (id: string, properties: Props) => {
93
+ current = current.map((animation) => {
94
+ if (animation.id !== id) return animation;
95
+ const before = classifyTweenPropertyGroup(animation.properties ?? {});
96
+ const merged = { ...(animation.properties ?? {}), ...properties };
97
+ const after = classifyTweenPropertyGroup(merged);
98
+ // Mixing is only a fault when the resize CAUSED it. A tween that already
99
+ // spanned two groups is an input, and splitting it is the point.
100
+ if (before !== undefined && after === undefined) recorded.corrupted.push(id);
101
+ return { ...animation, properties: merged, propertyGroup: after } as GsapAnimation;
102
+ });
103
+ };
104
+
105
+ const split = (id: string) => {
106
+ const target = current.find((animation) => animation.id === id);
107
+ if (!target) return;
108
+ const byGroup = new Map<string, Props>();
109
+ for (const [key, value] of Object.entries(target.properties ?? {})) {
110
+ const group = classifyTweenPropertyGroup({ [key]: value as number }) ?? "other";
111
+ byGroup.set(group, { ...(byGroup.get(group) ?? {}), [key]: value as number });
112
+ }
113
+ current = [
114
+ ...current.filter((animation) => animation.id !== id),
115
+ ...[...byGroup].map(([group, properties]) =>
116
+ tween(`#el-split-${group}`, properties, target.duration ?? 0),
117
+ ),
118
+ ];
119
+ };
120
+
121
+ const apply = (mutation: Record<string, unknown>, id: string | undefined) => {
122
+ const properties = (mutation.properties ?? {}) as Props;
123
+ if (mutation.type === "split-into-property-groups") return id && split(id);
124
+ if (!id) {
125
+ if (mutation.type === "add")
126
+ current = [...current, tween(`#el-added-${current.length}`, properties, 0)];
127
+ return;
128
+ }
129
+ mergeInto(id, properties);
130
+ const framed = (mutation.keyframes as Array<{ properties: Props }> | undefined) ?? [];
131
+ for (const frame of framed) mergeInto(id, frame.properties);
132
+ };
133
+
134
+ const commitMutation = vi.fn(async (_selection: unknown, mutation: Record<string, unknown>) => {
135
+ const id = mutation.animationId as string | undefined;
136
+ if (id && !current.some((animation) => animation.id === id)) {
137
+ recorded.rejected.push(`${String(mutation.type)}:${id}`);
138
+ throw new Error("animation not found");
139
+ }
140
+ apply(mutation, id);
141
+ });
142
+
143
+ return { commitMutation, recorded, animations: () => current };
144
+ }
145
+
146
+ type Dimension = Array<[string, () => GsapAnimation[]]>;
147
+
148
+ function dimension(
149
+ entries: Record<string, null | (() => GsapAnimation | GsapAnimation[])>,
150
+ ): Dimension {
151
+ return Object.entries(entries).map(([name, make]) => [
152
+ name,
153
+ () => {
154
+ if (!make) return [];
155
+ const made = make();
156
+ return Array.isArray(made) ? made : [made];
157
+ },
158
+ ]);
159
+ }
160
+
161
+ function buildCases() {
162
+ const dimensions = [dimension(SCALE), dimension(SIZE), dimension(POSITION), dimension(EXTRA)];
163
+ let combos: Array<Array<[string, () => GsapAnimation[]]>> = [[]];
164
+ for (const next of dimensions) {
165
+ combos = combos.flatMap((combo) => next.map((entry) => [...combo, entry]));
166
+ }
167
+ const labels = ["scale", "size", "position", "extra"];
168
+ return combos.map((combo) => ({
169
+ name: combo.map(([name], index) => `${labels[index]} ${name}`).join(" / "),
170
+ animations: combo.flatMap(([, make]) => make()),
171
+ }));
172
+ }
173
+
174
+ const CASES = buildCases();
175
+
176
+ it(`sweeps ${CASES.length} animated shapes without a stale id or a mixed tween`, async () => {
177
+ const failures: string[] = [];
178
+
179
+ for (const testCase of CASES) {
180
+ document.body.innerHTML = "";
181
+ const el = document.createElement("div");
182
+ el.id = "el";
183
+ el.setAttribute("data-hf-studio-original-box-width", "630");
184
+ el.setAttribute("data-hf-studio-original-box-height", "408");
185
+ document.body.append(el);
186
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
187
+
188
+ const source = fakeSource(testCase.animations);
189
+ try {
190
+ await tryGsapResizeIntercept(
191
+ { id: "el", selector: "#el", element: el } as DomEditSelection,
192
+ { width: 326, height: 213 },
193
+ testCase.animations,
194
+ null,
195
+ source.commitMutation as never,
196
+ async () => source.animations(),
197
+ );
198
+ } catch (error) {
199
+ // A rejection is recorded below; anything else is worth reporting as-is.
200
+ if (!(error instanceof Error) || error.message !== "animation not found") {
201
+ failures.push(`${testCase.name} — threw ${String(error)}`);
202
+ }
203
+ }
204
+
205
+ if (source.recorded.rejected.length > 0) {
206
+ failures.push(`${testCase.name} — stale id: ${source.recorded.rejected.join(", ")}`);
207
+ }
208
+ if (source.recorded.corrupted.length > 0) {
209
+ failures.push(`${testCase.name} — mixed tween: ${source.recorded.corrupted.join(", ")}`);
210
+ }
211
+ }
212
+
213
+ expect(failures).toEqual([]);
214
+ });
215
+
216
+ /**
217
+ * Which tween a resize edits when the element has several in the same group.
218
+ *
219
+ * A composition animates the same property more than once — a scale-in early
220
+ * and a scale-out late — and the one the user means is the one under the
221
+ * playhead. Editing the wrong one changes a moment they are not looking at and
222
+ * leaves the moment they ARE looking at unchanged, which reads as "the resize
223
+ * did nothing".
224
+ */
225
+ function scaleAt(id: string, position: number): GsapAnimation {
226
+ return {
227
+ ...tween(id, { scale: 1 }, 2),
228
+ position,
229
+ resolvedStart: position,
230
+ keyframes: {
231
+ keyframes: [
232
+ { percentage: 0, properties: { scale: 1 } },
233
+ { percentage: 100, properties: { scale: 1.4 } },
234
+ ],
235
+ },
236
+ } as unknown as GsapAnimation;
237
+ }
238
+
239
+ const PLAYHEADS: Array<[number, string]> = [
240
+ [0.5, "#el-early"],
241
+ [1.9, "#el-early"],
242
+ [3, "#el-early"],
243
+ [3.1, "#el-late"],
244
+ [4.5, "#el-late"],
245
+ [9, "#el-late"],
246
+ ];
247
+
248
+ it.each(PLAYHEADS)("at t=%s edits the tween under the playhead (%s)", async (time, expected) => {
249
+ document.body.innerHTML = "";
250
+ const el = document.createElement("div");
251
+ el.id = "el";
252
+ el.setAttribute("data-hf-studio-original-box-width", "630");
253
+ el.setAttribute("data-hf-studio-original-box-height", "408");
254
+ document.body.append(el);
255
+ usePlayerStore.setState({ currentTime: time, activeKeyframePct: null });
256
+
257
+ const animations = [scaleAt("#el-early", 0), scaleAt("#el-late", 4)];
258
+ const commitMutation = vi.fn();
259
+ await tryGsapResizeIntercept(
260
+ { id: "el", selector: "#el", element: el } as DomEditSelection,
261
+ { width: 326, height: 213 },
262
+ animations,
263
+ null,
264
+ commitMutation as never,
265
+ async () => animations,
266
+ );
267
+
268
+ const touched = new Set(
269
+ commitMutation.mock.calls
270
+ .map((call) => (call[1] as { animationId?: string }).animationId)
271
+ .filter((id): id is string => id != null),
272
+ );
273
+ expect([...touched]).toEqual([expected]);
274
+ });
@@ -313,8 +313,8 @@ describe("useGsapAwareEditing anchored resize", () => {
313
313
  act(() => h.root.unmount());
314
314
  });
315
315
 
316
- it("does not apply the anchor twice when scale route already settles the drop point", async () => {
317
- mocks.resize.mockResolvedValue({ status: "persisted" });
316
+ it("does not apply the anchor twice when the resize already settled the drop point", async () => {
317
+ mocks.resize.mockResolvedValue({ status: "persisted", ownsDragOffset: true });
318
318
  const scale = { propertyGroup: "scale" } as GsapAnimation;
319
319
  const h = mountResizeHandler([scale]);
320
320
  await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
@@ -322,4 +322,21 @@ describe("useGsapAwareEditing anchored resize", () => {
322
322
  expect(h.fallback).not.toHaveBeenCalled();
323
323
  act(() => h.root.unmount());
324
324
  });
325
+
326
+ /**
327
+ * The same element, and the resize says it did NOT settle the drop point.
328
+ *
329
+ * This is the shape that broke: an element whose scale is an instant hold has
330
+ * a scale-group tween and still commits width/height. Reading the tweens said
331
+ * "scale route, it settles its own position", so the offset was withheld,
332
+ * nobody wrote it, and the element snapped back on every drag.
333
+ */
334
+ it("applies the anchor when the resize leaves the drop point to the caller", async () => {
335
+ mocks.resize.mockResolvedValue({ status: "persisted" });
336
+ const scale = { propertyGroup: "scale" } as GsapAnimation;
337
+ const h = mountResizeHandler([scale]);
338
+ await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
339
+ expect(mocks.drag).toHaveBeenCalledTimes(1);
340
+ act(() => h.root.unmount());
341
+ });
325
342
  });
@@ -258,13 +258,21 @@ export function useGsapAwareEditing({
258
258
  makeFetchFallback(selection),
259
259
  );
260
260
  assertGsapEditPersisted(outcome);
261
+ // What the resize actually did, not what its animations suggest
262
+ // it would do. An element whose scale is an instant hold has a
263
+ // scale-group tween and still commits width/height, so guessing
264
+ // from the tweens withheld an offset nobody had written and the
265
+ // element snapped back to its authored position on every drag.
266
+ const ownsDragOffset =
267
+ outcome.status === "persisted" && outcome.ownsDragOffset === true;
261
268
  logResize("intercept-handled", {
262
269
  scaleRoute,
263
- willForwardOffset: !!(offset && !scaleRoute),
270
+ ownsDragOffset,
271
+ willForwardOffset: !!(offset && !ownsDragOffset),
264
272
  });
265
- // Scale-route resize persists its residual position internally.
266
- // Width/height persists the already-settled anchor through drag.
267
- if (offset && !scaleRoute) {
273
+ // A resize that moved the element itself has already written
274
+ // where it landed. Everything else leaves the anchor to the drag.
275
+ if (offset && !ownsDragOffset) {
268
276
  const dragOutcome = await tryGsapDragIntercept(
269
277
  selection,
270
278
  offset,
@@ -275,7 +283,7 @@ export function useGsapAwareEditing({
275
283
  );
276
284
  assertGsapEditPersisted(dragOutcome);
277
285
  }
278
- logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
286
+ logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size");
279
287
  return;
280
288
  } catch (error) {
281
289
  trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");