@hyperframes/studio 0.7.99 → 0.7.101

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 (35) hide show
  1. package/dist/assets/{hyperframes-player-BY5RLMBA.js → hyperframes-player-BxSY8bs4.js} +1 -1
  2. package/dist/assets/{index-CQ07_DLG.js → index-BlRPDw0A.js} +1 -1
  3. package/dist/assets/{index-DMSqmZM6.js → index-DF06yGPO.js} +200 -200
  4. package/dist/assets/{index-DeXLAktv.js → index-Dkz5RbFX.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +473 -359
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/App.tsx +2 -2
  10. package/src/components/StudioHeader.test.ts +28 -0
  11. package/src/components/StudioHeader.tsx +21 -2
  12. package/src/components/StudioLeftSidebar.tsx +2 -2
  13. package/src/components/nle/NLEContext.tsx +16 -7
  14. package/src/components/nle/TimelineResizeDivider.tsx +4 -11
  15. package/src/components/sidebar/LeftSidebar.storage.test.ts +28 -0
  16. package/src/components/sidebar/LeftSidebar.tsx +18 -3
  17. package/src/contexts/PanelLayoutContext.tsx +6 -3
  18. package/src/hooks/gsapEditOutcome.ts +19 -1
  19. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +47 -1
  20. package/src/hooks/gsapKeyframeCacheHelpers.ts +31 -3
  21. package/src/hooks/gsapResizeGeometrySweep.test.ts +289 -0
  22. package/src/hooks/gsapResizeIntercept.test.ts +2 -2
  23. package/src/hooks/gsapResizeIntercept.ts +37 -14
  24. package/src/hooks/gsapResizeMixedTween.test.ts +168 -0
  25. package/src/hooks/gsapResizeSweep.test.ts +274 -0
  26. package/src/hooks/useGsapAwareEditing.test.tsx +19 -2
  27. package/src/hooks/useGsapAwareEditing.ts +13 -5
  28. package/src/hooks/useGsapTweenCache.ts +16 -19
  29. package/src/hooks/usePanelLayout.test.ts +117 -0
  30. package/src/hooks/usePanelLayout.ts +121 -39
  31. package/src/player/components/Player.test.ts +13 -0
  32. package/src/player/components/Player.tsx +7 -1
  33. package/src/utils/clipboard.ts +1 -1
  34. package/src/utils/fitPanels.test.ts +164 -0
  35. package/src/utils/fitPanels.ts +151 -0
@@ -0,0 +1,289 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * What the resize COMMITS, checked as geometry rather than as structure.
4
+ *
5
+ * The structural sweep beside this one proves a resize never addresses a
6
+ * missing animation and never leaves a tween spanning two property groups.
7
+ * Neither says the box ends up the size the user dragged it to, which is the
8
+ * thing they are actually looking at.
9
+ *
10
+ * The invariant is split by who owns the drop point, because the two halves are
11
+ * genuinely different jobs:
12
+ *
13
+ * - The committed size or scale must reproduce the RENDERED box the user
14
+ * dropped, whatever rotation is on the element. This is the resize's job in
15
+ * every route.
16
+ * - When the resize reports `ownsDragOffset`, the box must also land on the
17
+ * drop POINT, because it has taken responsibility for the position. When it
18
+ * does not, position is the drag's job and is not asserted here.
19
+ *
20
+ * Rotation is the reason this exists. The committed scale is worked out from
21
+ * the element's CSS box, and a rotated element's rendered box is not its CSS
22
+ * box — so the two are only equal if the drafted size is in CSS-box terms all
23
+ * the way through. A sweep across rotations is what tells us it is.
24
+ */
25
+ import { afterEach, expect, it, vi } from "vitest";
26
+ import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
27
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
28
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
29
+ import { usePlayerStore } from "../player/store/playerStore";
30
+ import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
31
+
32
+ afterEach(() => {
33
+ vi.restoreAllMocks();
34
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
35
+ document.body.innerHTML = "";
36
+ });
37
+
38
+ const LAYOUT = { left: 120, top: 520 };
39
+
40
+ interface Pose {
41
+ box: { w: number; h: number };
42
+ pos: { x: number; y: number };
43
+ scale: { x: number; y: number };
44
+ }
45
+
46
+ /** The AABB a browser reports for `translate() rotate() scale()` about the centre. */
47
+ function renderRect(pose: Pose, rotationDeg: number) {
48
+ const rad = (rotationDeg * Math.PI) / 180;
49
+ const [cos, sin] = [Math.abs(Math.cos(rad)), Math.abs(Math.sin(rad))];
50
+ const [sw, sh] = [pose.box.w * pose.scale.x, pose.box.h * pose.scale.y];
51
+ const w = sw * cos + sh * sin;
52
+ const h = sw * sin + sh * cos;
53
+ const cx = LAYOUT.left + pose.box.w / 2 + pose.pos.x;
54
+ const cy = LAYOUT.top + pose.box.h / 2 + pose.pos.y;
55
+ return { x: cx - w / 2, y: cy - h / 2, w, h };
56
+ }
57
+
58
+ type Props = Record<string, number>;
59
+
60
+ function tween(id: string, properties: Props, duration: number): GsapAnimation {
61
+ return {
62
+ id,
63
+ targetSelector: "#el",
64
+ propertyGroup: classifyTweenPropertyGroup(properties),
65
+ method: "to",
66
+ properties,
67
+ position: 0,
68
+ resolvedStart: 0,
69
+ duration,
70
+ ...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
71
+ } as unknown as GsapAnimation;
72
+ }
73
+
74
+ interface Case {
75
+ name: string;
76
+ /** The element's untransformed CSS box. */
77
+ box: { w: number; h: number };
78
+ /** Where it sat, and at what scale, before the gesture. */
79
+ base: { x: number; y: number };
80
+ liveScale: { x: number; y: number };
81
+ rotation: number;
82
+ /** The box the user dragged to, and where the draft put it. */
83
+ drop: { w: number; h: number; x: number; y: number };
84
+ animations: () => GsapAnimation[];
85
+ /** Whether this route takes responsibility for where the box lands. */
86
+ settles: boolean;
87
+ }
88
+
89
+ const ROTATIONS = [0, -8, 45, -47, 90, 180];
90
+
91
+ /**
92
+ * The routes a resize can take, and whether each SETTLES the drop point.
93
+ *
94
+ * Only a committed scale moves the box: it renders about the element centre
95
+ * rather than the dragged corner, so the route measures the difference and
96
+ * writes the position. Every other route commits width and height and moves
97
+ * nothing, which leaves the anchor to the drag. An element whose scale is an
98
+ * instant hold has a scale tween and still commits size, so it belongs with
99
+ * the size routes here however it looks from the animation list.
100
+ */
101
+ const ROUTES = {
102
+ "scale tween": { animations: () => [tween("#el-scale", { scale: 1 }, 2)], settles: true },
103
+ "scale longhands": {
104
+ animations: () => [tween("#el-scale", { scaleX: 1, scaleY: 1 }, 2)],
105
+ settles: true,
106
+ },
107
+ "scale instant hold": { animations: () => [tween("#el-scale", { scale: 1 }, 0)], settles: false },
108
+ "size tween": {
109
+ animations: () => [tween("#el-size", { width: 630, height: 408 }, 2)],
110
+ settles: false,
111
+ },
112
+ "size instant hold": {
113
+ animations: () => [tween("#el-size", { width: 630, height: 408 }, 0)],
114
+ settles: false,
115
+ },
116
+ } as const;
117
+
118
+ function buildCases(): Case[] {
119
+ const cases: Case[] = [];
120
+ for (const [routeName, route] of Object.entries(ROUTES)) {
121
+ for (const rotation of ROTATIONS) {
122
+ for (const [dropName, drop] of Object.entries({
123
+ shrink: { w: 326, h: 213, x: 60, y: 40 },
124
+ grow: { w: 980, h: 640, x: -120, y: -90 },
125
+ "near zero": { w: 12, h: 8, x: 200, y: 160 },
126
+ "aspect flip": { w: 900, h: 90, x: 10, y: 10 },
127
+ })) {
128
+ cases.push({
129
+ name: `${routeName} / rotation ${rotation} / ${dropName}`,
130
+ box: { w: 630, h: 408 },
131
+ base: { x: 40, y: 25 },
132
+ liveScale: { x: 1, y: 1 },
133
+ rotation,
134
+ drop,
135
+ animations: route.animations,
136
+ settles: route.settles,
137
+ });
138
+ }
139
+ }
140
+ }
141
+ return cases;
142
+ }
143
+
144
+ const CASES = buildCases();
145
+
146
+ /** The scale and size the run committed, read at the playhead. */
147
+ function committed(calls: unknown[][]) {
148
+ let scale: { x: number; y: number } | null = null;
149
+ let size: { w: number; h: number } | null = null;
150
+ const take = (source: Props | undefined) => {
151
+ if (!source) return;
152
+ const sx = source.scaleX ?? source.scale;
153
+ const sy = source.scaleY ?? source.scale;
154
+ if (sx != null && sy != null) scale = { x: sx, y: sy };
155
+ if (source.width != null && source.height != null) {
156
+ size = { w: source.width, h: source.height };
157
+ }
158
+ };
159
+ for (const call of calls) {
160
+ const mutation = call[1] as {
161
+ properties?: Props;
162
+ percentage?: number;
163
+ keyframes?: Array<{ percentage: number; properties: Props }>;
164
+ };
165
+ if (mutation.keyframes) {
166
+ for (const frame of mutation.keyframes) if (frame.percentage === 0) take(frame.properties);
167
+ continue;
168
+ }
169
+ if (mutation.percentage != null && mutation.percentage !== 0) continue;
170
+ take(mutation.properties);
171
+ }
172
+ return { scale, size };
173
+ }
174
+
175
+ /** The element as the gesture leaves it: drafted box, base pose, live pose. */
176
+ function mountCase(testCase: Case, live: Pose) {
177
+ const el = document.createElement("div");
178
+ el.id = "el";
179
+ el.setAttribute("data-hf-studio-original-box-width", String(testCase.box.w));
180
+ el.setAttribute("data-hf-studio-original-box-height", String(testCase.box.h));
181
+ el.setAttribute("data-hf-drag-gsap-base-x", String(testCase.base.x));
182
+ el.setAttribute("data-hf-drag-gsap-base-y", String(testCase.base.y));
183
+ el.setAttribute("data-hf-studio-box-size", "true");
184
+ el.style.width = `${testCase.drop.w}px`;
185
+ el.style.height = `${testCase.drop.h}px`;
186
+ document.body.append(el);
187
+
188
+ el.getBoundingClientRect = () => {
189
+ const w = Number.parseFloat(el.style.width) || live.box.w;
190
+ const h = Number.parseFloat(el.style.height) || live.box.h;
191
+ const rect = renderRect({ ...live, box: { w, h } }, testCase.rotation);
192
+ return { ...rect, width: rect.w, height: rect.h } as unknown as DOMRect;
193
+ };
194
+ const gsap = {
195
+ set: (_target: Element, vars: Props) => {
196
+ if (vars.x != null) live.pos.x = vars.x;
197
+ if (vars.y != null) live.pos.y = vars.y;
198
+ if (vars.scaleX != null) live.scale.x = vars.scaleX;
199
+ if (vars.scaleY != null) live.scale.y = vars.scaleY;
200
+ },
201
+ getProperty: (_target: Element, prop: string) =>
202
+ ({
203
+ scaleX: live.scale.x,
204
+ scaleY: live.scale.y,
205
+ x: live.pos.x,
206
+ y: live.pos.y,
207
+ rotation: testCase.rotation,
208
+ })[prop] ?? 0,
209
+ };
210
+ Object.assign(window, { gsap });
211
+ const iframe = {
212
+ contentWindow: { gsap, __timelines: { main: { getChildren: () => [] } } },
213
+ contentDocument: document,
214
+ } as unknown as HTMLIFrameElement;
215
+ return { el, iframe };
216
+ }
217
+
218
+ /** One run: mount the case, drive the intercept, hand the result to the judge. */
219
+ async function runCase(testCase: Case): Promise<string[]> {
220
+ document.body.innerHTML = "";
221
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
222
+ const live: Pose = {
223
+ box: { ...testCase.box },
224
+ pos: { x: testCase.drop.x, y: testCase.drop.y },
225
+ scale: { ...testCase.liveScale },
226
+ };
227
+ const { el, iframe } = mountCase(testCase, live);
228
+ const dropped = el.getBoundingClientRect();
229
+ const animations = testCase.animations();
230
+ const commitMutation = vi.fn();
231
+
232
+ const outcome = await tryGsapResizeIntercept(
233
+ { id: "el", selector: "#el", element: el } as DomEditSelection,
234
+ { width: testCase.drop.w, height: testCase.drop.h },
235
+ animations,
236
+ iframe,
237
+ commitMutation as never,
238
+ async () => animations,
239
+ );
240
+
241
+ const { scale, size } = committed(commitMutation.mock.calls);
242
+ const settled = renderRect(
243
+ { box: size ?? testCase.box, pos: { ...live.pos }, scale: scale ?? testCase.liveScale },
244
+ testCase.rotation,
245
+ );
246
+ const owns = outcome.status === "persisted" && outcome.ownsDragOffset === true;
247
+ return judge(testCase, dropped, settled, owns);
248
+ }
249
+
250
+ /**
251
+ * What the run got wrong, if anything. 1px: position rounds to whole pixels and
252
+ * scale keeps three decimals.
253
+ */
254
+ function judge(
255
+ testCase: Case,
256
+ dropped: DOMRect,
257
+ settled: { x: number; y: number; w: number; h: number },
258
+ owns: boolean,
259
+ ): string[] {
260
+ const off = (a: number, b: number) => Math.abs(a - b) > 1;
261
+ if (off(settled.w, dropped.width) || off(settled.h, dropped.height)) {
262
+ return [
263
+ `${testCase.name} — box ${settled.w.toFixed(1)}x${settled.h.toFixed(1)}` +
264
+ `, dropped ${dropped.width.toFixed(1)}x${dropped.height.toFixed(1)}`,
265
+ ];
266
+ }
267
+ // Claiming the drop point is only honest for the routes that settle it. The
268
+ // size routes commit width and height and move nothing, so the drag still owns
269
+ // the anchor — and a run that claims otherwise makes the caller withhold an
270
+ // offset nobody writes. The position check below cannot see that on its own:
271
+ // the fixture's live pose starts at the drop, which is where the gesture
272
+ // leaves it, so a size route trivially "lands" there.
273
+ if (owns !== testCase.settles) {
274
+ return [`${testCase.name} — ownsDragOffset ${owns}, expected ${testCase.settles}`];
275
+ }
276
+ if (owns && (off(settled.x, dropped.x) || off(settled.y, dropped.y))) {
277
+ return [
278
+ `${testCase.name} — landed ${settled.x.toFixed(1)},${settled.y.toFixed(1)}` +
279
+ `, dropped ${dropped.x.toFixed(1)},${dropped.y.toFixed(1)}`,
280
+ ];
281
+ }
282
+ return [];
283
+ }
284
+
285
+ it(`sweeps ${CASES.length} rotations and drops for the box the user dropped`, async () => {
286
+ const failures: string[] = [];
287
+ for (const testCase of CASES) failures.push(...(await runCase(testCase)));
288
+ expect(failures).toEqual([]);
289
+ });
@@ -98,7 +98,7 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
98
98
  commitMutation,
99
99
  );
100
100
 
101
- expect(handled).toEqual({ status: "persisted" });
101
+ expect(handled).toMatchObject({ status: "persisted" });
102
102
  expect(commitMutation).toHaveBeenCalledTimes(1);
103
103
  expect(commitMutation.mock.calls[0]![1]).toEqual({
104
104
  type: "update-properties",
@@ -246,7 +246,7 @@ async function runResize(
246
246
  commitMutation as never,
247
247
  async () => [keyframedScaleFixture()],
248
248
  );
249
- expect(handled).toEqual({ status: "persisted" });
249
+ expect(handled).toMatchObject({ status: "persisted" });
250
250
  return committed;
251
251
  }
252
252
 
@@ -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
+ });