@hyperframes/studio 0.7.96 → 0.7.97

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.96",
3
+ "version": "0.7.97",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.7.96",
51
- "@hyperframes/player": "0.7.96",
52
- "@hyperframes/parsers": "0.7.96",
53
- "@hyperframes/sdk": "0.7.96",
54
- "@hyperframes/studio-server": "0.7.96"
50
+ "@hyperframes/parsers": "0.7.97",
51
+ "@hyperframes/core": "0.7.97",
52
+ "@hyperframes/player": "0.7.97",
53
+ "@hyperframes/sdk": "0.7.97",
54
+ "@hyperframes/studio-server": "0.7.97"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -67,7 +67,7 @@
67
67
  "vite": "^6.4.2",
68
68
  "vitest": "^3.2.4",
69
69
  "zustand": "^5.0.0",
70
- "@hyperframes/producer": "0.7.96"
70
+ "@hyperframes/producer": "0.7.97"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "react": "19",
@@ -12,6 +12,8 @@ import {
12
12
  STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
13
13
  STUDIO_ORIGINAL_WIDTH_ATTR,
14
14
  STUDIO_ORIGINAL_HEIGHT_ATTR,
15
+ STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
16
+ STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
15
17
  STUDIO_ORIGINAL_MIN_WIDTH_ATTR,
16
18
  STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
17
19
  STUDIO_ORIGINAL_MAX_WIDTH_ATTR,
@@ -358,6 +360,18 @@ function writeStudioBoxSizeVars(
358
360
  element: HTMLElement,
359
361
  size: { width: number; height: number },
360
362
  ): void {
363
+ // Keep the measurement on its own migration-safe guard. Elements drafted by
364
+ // an older Studio can already carry the box-size marker without these newer
365
+ // attributes; the next resize still reaches this function before its width
366
+ // and height are overwritten, so this is the last honest layout box to save.
367
+ // Offset sizes are layout values, so a running scale animation does not
368
+ // distort them.
369
+ if (!element.hasAttribute(STUDIO_ORIGINAL_BOX_WIDTH_ATTR)) {
370
+ element.setAttribute(STUDIO_ORIGINAL_BOX_WIDTH_ATTR, String(element.offsetWidth));
371
+ }
372
+ if (!element.hasAttribute(STUDIO_ORIGINAL_BOX_HEIGHT_ATTR)) {
373
+ element.setAttribute(STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, String(element.offsetHeight));
374
+ }
361
375
  if (!element.hasAttribute(STUDIO_BOX_SIZE_ATTR)) {
362
376
  element.setAttribute(STUDIO_ORIGINAL_WIDTH_ATTR, element.style.getPropertyValue("width"));
363
377
  element.setAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR, element.style.getPropertyValue("height"));
@@ -17,6 +17,8 @@ import {
17
17
  STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
18
18
  STUDIO_ORIGINAL_WIDTH_ATTR,
19
19
  STUDIO_ORIGINAL_HEIGHT_ATTR,
20
+ STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
21
+ STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
20
22
  STUDIO_ORIGINAL_MIN_WIDTH_ATTR,
21
23
  STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
22
24
  STUDIO_ORIGINAL_MAX_WIDTH_ATTR,
@@ -222,6 +224,9 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => {
222
224
  { type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: null },
223
225
  { type: "inline-style", property: "height", value: "150px" },
224
226
  { type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: null },
227
+ // Measurements, so they are cleared without restoring a style.
228
+ { type: "attribute", property: STUDIO_ORIGINAL_BOX_WIDTH_ATTR, value: null },
229
+ { type: "attribute", property: STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, value: null },
225
230
  { type: "inline-style", property: "min-width", value: "0px" },
226
231
  { type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: null },
227
232
  { type: "inline-style", property: "min-height", value: "0px" },
@@ -257,11 +262,25 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => {
257
262
 
258
263
  it("clear: bare element emits only null ops — no style restores fire when orig attrs are absent", () => {
259
264
  const ops = buildClearBoxSizePatches(div());
260
- // 3 fixed (studio-width, studio-height, box-size marker) + 14 attr-null pushes (one per BOX_SIZE_ORIG_ATTR)
261
- expect(ops).toHaveLength(17);
265
+ // 3 fixed (studio-width, studio-height, box-size marker) + 16 attr-null pushes (one per BOX_SIZE_ORIG_ATTR)
266
+ expect(ops).toHaveLength(19);
262
267
  expect(ops.every((op) => op.value === null)).toBe(true);
263
268
  });
264
269
 
270
+ it("backfills the measured box on a legacy element that already has the resize marker", () => {
271
+ const e = div();
272
+ e.setAttribute(STUDIO_BOX_SIZE_ATTR, "true");
273
+ Object.defineProperties(e, {
274
+ offsetWidth: { configurable: true, value: 630 },
275
+ offsetHeight: { configurable: true, value: 252 },
276
+ });
277
+
278
+ applyStudioBoxSize(e, { width: 320, height: 128 });
279
+
280
+ expect(e.getAttribute(STUDIO_ORIGINAL_BOX_WIDTH_ATTR)).toBe("630");
281
+ expect(e.getAttribute(STUDIO_ORIGINAL_BOX_HEIGHT_ATTR)).toBe("252");
282
+ });
283
+
265
284
  it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
266
285
  const e = populatedBoxEl();
267
286
  assertClearCoversKeys(buildBoxSizePatches(e), buildClearBoxSizePatches(e));
@@ -13,6 +13,8 @@ import {
13
13
  STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
14
14
  STUDIO_ORIGINAL_WIDTH_ATTR,
15
15
  STUDIO_ORIGINAL_HEIGHT_ATTR,
16
+ STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
17
+ STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
16
18
  STUDIO_ORIGINAL_MIN_WIDTH_ATTR,
17
19
  STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
18
20
  STUDIO_ORIGINAL_MAX_WIDTH_ATTR,
@@ -135,6 +137,9 @@ const BOX_SIZE_STYLE_PROPS = [
135
137
  const BOX_SIZE_ORIG_ATTRS: ReadonlyArray<[string, string]> = [
136
138
  [STUDIO_ORIGINAL_WIDTH_ATTR, "width"],
137
139
  [STUDIO_ORIGINAL_HEIGHT_ATTR, "height"],
140
+ // Records a measurement rather than a style, so it restores nothing.
141
+ [STUDIO_ORIGINAL_BOX_WIDTH_ATTR, ""],
142
+ [STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, ""],
138
143
  [STUDIO_ORIGINAL_MIN_WIDTH_ATTR, "min-width"],
139
144
  [STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, "min-height"],
140
145
  [STUDIO_ORIGINAL_MAX_WIDTH_ATTR, "max-width"],
@@ -17,6 +17,8 @@ import {
17
17
  STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
18
18
  STUDIO_ORIGINAL_WIDTH_ATTR,
19
19
  STUDIO_ORIGINAL_HEIGHT_ATTR,
20
+ STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
21
+ STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
20
22
  STUDIO_ORIGINAL_MIN_WIDTH_ATTR,
21
23
  STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
22
24
  STUDIO_ORIGINAL_MAX_WIDTH_ATTR,
@@ -60,6 +62,8 @@ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapsho
60
62
  marker: element.getAttribute(STUDIO_BOX_SIZE_ATTR),
61
63
  originalWidth: element.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR),
62
64
  originalHeight: element.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR),
65
+ originalBoxWidth: element.getAttribute(STUDIO_ORIGINAL_BOX_WIDTH_ATTR),
66
+ originalBoxHeight: element.getAttribute(STUDIO_ORIGINAL_BOX_HEIGHT_ATTR),
63
67
  originalMinWidth: element.getAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR),
64
68
  originalMinHeight: element.getAttribute(STUDIO_ORIGINAL_MIN_HEIGHT_ATTR),
65
69
  originalMaxWidth: element.getAttribute(STUDIO_ORIGINAL_MAX_WIDTH_ATTR),
@@ -128,6 +132,8 @@ export function restoreStudioBoxSize(element: HTMLElement, previous: StudioBoxSi
128
132
  restoreAttribute(element, STUDIO_BOX_SIZE_ATTR, previous.marker);
129
133
  restoreAttribute(element, STUDIO_ORIGINAL_WIDTH_ATTR, previous.originalWidth);
130
134
  restoreAttribute(element, STUDIO_ORIGINAL_HEIGHT_ATTR, previous.originalHeight);
135
+ restoreAttribute(element, STUDIO_ORIGINAL_BOX_WIDTH_ATTR, previous.originalBoxWidth);
136
+ restoreAttribute(element, STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, previous.originalBoxHeight);
131
137
  restoreAttribute(element, STUDIO_ORIGINAL_MIN_WIDTH_ATTR, previous.originalMinWidth);
132
138
  restoreAttribute(element, STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, previous.originalMinHeight);
133
139
  restoreAttribute(element, STUDIO_ORIGINAL_MAX_WIDTH_ATTR, previous.originalMaxWidth);
@@ -16,6 +16,18 @@ export const STUDIO_ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate
16
16
  export const STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR = "data-hf-studio-original-inline-translate";
17
17
  export const STUDIO_ORIGINAL_WIDTH_ATTR = "data-hf-studio-original-width";
18
18
  export const STUDIO_ORIGINAL_HEIGHT_ATTR = "data-hf-studio-original-height";
19
+ /**
20
+ * The element's laid-out box before a resize draft touched it, in CSS pixels.
21
+ *
22
+ * The two attributes above record the element's INLINE width and height so a
23
+ * reset can put them back, and are empty for the usual case of an element sized
24
+ * by the stylesheet. That made them useless as a measurement, and the resize
25
+ * intercept, which needs the original box to work out a scale, fell back to a
26
+ * hardcoded guess and produced a wildly wrong one. These record the measurement
27
+ * instead, and restore nothing.
28
+ */
29
+ export const STUDIO_ORIGINAL_BOX_WIDTH_ATTR = "data-hf-studio-original-box-width";
30
+ export const STUDIO_ORIGINAL_BOX_HEIGHT_ATTR = "data-hf-studio-original-box-height";
19
31
  export const STUDIO_ORIGINAL_MIN_WIDTH_ATTR = "data-hf-studio-original-min-width";
20
32
  export const STUDIO_ORIGINAL_MIN_HEIGHT_ATTR = "data-hf-studio-original-min-height";
21
33
  export const STUDIO_ORIGINAL_MAX_WIDTH_ATTR = "data-hf-studio-original-max-width";
@@ -70,6 +82,8 @@ export interface StudioBoxSizeSnapshot {
70
82
  marker: string | null;
71
83
  originalWidth: string | null;
72
84
  originalHeight: string | null;
85
+ originalBoxWidth: string | null;
86
+ originalBoxHeight: string | null;
73
87
  originalMinWidth: string | null;
74
88
  originalMinHeight: string | null;
75
89
  originalMaxWidth: string | null;
@@ -0,0 +1,394 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * The scale-route resize invariant, swept over the shapes a composition
4
+ * actually produces: WHERE THE USER DROPS THE BOX IS WHERE IT LANDS.
5
+ *
6
+ * Each case builds the geometry the browser reports — a CSS box at a layout
7
+ * position, transformed by GSAP's translate/rotate/scale about the element
8
+ * centre — drives one resize through the real intercept, then re-renders the
9
+ * committed scale and the PERSISTED position and checks the box is still on
10
+ * the drop point. Asserting on the persisted values rather than the live ones
11
+ * is the point: every bug in this class showed as a correct-looking drop frame
12
+ * followed by the element sliding to whatever got written to disk.
13
+ *
14
+ * The model is calibrated against real `hf-resize-debug` output: the same
15
+ * inputs reproduce the drop rect, the post-commit rect and the rect width the
16
+ * browser reported to three decimal places.
17
+ */
18
+ import { afterEach, describe, expect, it, vi } from "vitest";
19
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
20
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
21
+ import { usePlayerStore } from "../player/store/playerStore";
22
+ import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
23
+
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
27
+ document.body.innerHTML = "";
28
+ });
29
+
30
+ interface Pose {
31
+ /** Untransformed CSS box, in px. */
32
+ box: { w: number; h: number };
33
+ /** GSAP translate. */
34
+ pos: { x: number; y: number };
35
+ /** GSAP scale, per axis. */
36
+ scale: { x: number; y: number };
37
+ }
38
+
39
+ /** Layout position of the untransformed box, shared by every pose in a case. */
40
+ const LAYOUT = { left: 120, top: 520 };
41
+
42
+ /**
43
+ * The AABB a browser reports for `translate() rotate() scale()` about the
44
+ * element centre — the same rect `getBoundingClientRect` returns, which is why
45
+ * a rotated element's anchor is approximate rather than corner-exact.
46
+ */
47
+ function renderRect(
48
+ pose: Pose,
49
+ rotationDeg: number,
50
+ ): { x: number; y: number; w: number; h: number } {
51
+ const rad = (rotationDeg * Math.PI) / 180;
52
+ const [cos, sin] = [Math.abs(Math.cos(rad)), Math.abs(Math.sin(rad))];
53
+ const [sw, sh] = [pose.box.w * pose.scale.x, pose.box.h * pose.scale.y];
54
+ const w = sw * cos + sh * sin;
55
+ const h = sw * sin + sh * cos;
56
+ const cx = LAYOUT.left + pose.box.w / 2 + pose.pos.x;
57
+ const cy = LAYOUT.top + pose.box.h / 2 + pose.pos.y;
58
+ return { x: cx - w / 2, y: cy - h / 2, w, h };
59
+ }
60
+
61
+ interface ResizeCase {
62
+ name: string;
63
+ /** Untransformed box the stylesheet (or an inline style) gives the element. */
64
+ box: { w: number; h: number };
65
+ /** Where the element sat before the gesture. */
66
+ base: { x: number; y: number };
67
+ /** Scale already on the element — 1 for a never-resized one. */
68
+ liveScale: { x: number; y: number };
69
+ rotation?: number;
70
+ /** The box the user dragged to, and where the draft put it. */
71
+ drop: { w: number; h: number; x: number; y: number };
72
+ /** Sized inline rather than by a stylesheet. */
73
+ inlineSized?: boolean;
74
+ /** The tween states scaleX/scaleY rather than the shorthand. */
75
+ longhandTween?: boolean;
76
+ /** What already writes this element's position. */
77
+ positionWrite?: "static-set" | "keyframed-tween" | "none";
78
+ }
79
+
80
+ function createResizeElement(testCase: ResizeCase): HTMLElement {
81
+ const el = document.createElement("div");
82
+ el.id = "clip";
83
+ if (testCase.inlineSized) {
84
+ el.setAttribute("data-hf-studio-original-width", `${testCase.box.w}px`);
85
+ el.setAttribute("data-hf-studio-original-height", `${testCase.box.h}px`);
86
+ } else {
87
+ el.setAttribute("data-hf-studio-original-box-width", `${testCase.box.w}`);
88
+ el.setAttribute("data-hf-studio-original-box-height", `${testCase.box.h}`);
89
+ el.setAttribute("data-hf-studio-original-width", "");
90
+ el.setAttribute("data-hf-studio-original-height", "");
91
+ }
92
+ return el;
93
+ }
94
+
95
+ function scaleTween(longhand: boolean): GsapAnimation {
96
+ const at = (v: number) => (longhand ? { scaleX: v, scaleY: v } : { scale: v });
97
+ return {
98
+ id: "#clip-to-0-scale",
99
+ targetSelector: "#clip",
100
+ propertyGroup: "scale",
101
+ method: "to",
102
+ properties: at(1),
103
+ position: 0,
104
+ resolvedStart: 0,
105
+ duration: 2,
106
+ keyframes: {
107
+ keyframes: [
108
+ { percentage: 0, properties: at(1) },
109
+ { percentage: 100, properties: at(1.08) },
110
+ ],
111
+ },
112
+ } as unknown as GsapAnimation;
113
+ }
114
+
115
+ function positionAnimation(
116
+ kind: NonNullable<ResizeCase["positionWrite"]>,
117
+ base: { x: number; y: number },
118
+ ) {
119
+ if (kind === "none") return null;
120
+ if (kind === "static-set") {
121
+ return {
122
+ id: "#clip-set-0-position",
123
+ targetSelector: "#clip",
124
+ propertyGroup: "position",
125
+ method: "set",
126
+ properties: { x: base.x, y: base.y },
127
+ position: 0,
128
+ resolvedStart: 0,
129
+ duration: 0,
130
+ global: true,
131
+ } as unknown as GsapAnimation;
132
+ }
133
+ return {
134
+ id: "#clip-to-0-position",
135
+ targetSelector: "#clip",
136
+ propertyGroup: "position",
137
+ method: "to",
138
+ properties: { x: base.x, y: base.y },
139
+ position: 0,
140
+ resolvedStart: 0,
141
+ duration: 2,
142
+ keyframes: {
143
+ keyframes: [
144
+ { percentage: 0, properties: { x: base.x, y: base.y } },
145
+ { percentage: 100, properties: { x: base.x + 40, y: base.y + 40 } },
146
+ ],
147
+ },
148
+ } as unknown as GsapAnimation;
149
+ }
150
+
151
+ /** Every x/y a run wrote, in commit order. */
152
+ function persistedPositions(calls: unknown[][]): Array<{ x?: number; y?: number }> {
153
+ const written: Array<{ x?: number; y?: number }> = [];
154
+ for (const call of calls) {
155
+ const mutation = call[1] as {
156
+ properties?: Record<string, number>;
157
+ keyframes?: Array<{ properties: Record<string, number> }>;
158
+ x?: number;
159
+ y?: number;
160
+ };
161
+ const sources = [mutation.properties, ...(mutation.keyframes ?? []).map((k) => k.properties)];
162
+ if (mutation.x != null || mutation.y != null) written.push({ x: mutation.x, y: mutation.y });
163
+ for (const source of sources) {
164
+ if (source && (source.x != null || source.y != null))
165
+ written.push({ x: source.x, y: source.y });
166
+ }
167
+ }
168
+ return written;
169
+ }
170
+
171
+ /** The scale the run committed AT THE PLAYHEAD (percentage 0 here). */
172
+ function persistedScale(calls: unknown[][]): { x: number; y: number } | null {
173
+ let found: { x: number; y: number } | null = null;
174
+ const take = (source: Record<string, number> | undefined) => {
175
+ if (!source) return;
176
+ const x = source.scaleX ?? source.scale;
177
+ const y = source.scaleY ?? source.scale;
178
+ if (x != null && y != null) found = { x, y };
179
+ };
180
+ for (const call of calls) {
181
+ const mutation = call[1] as {
182
+ properties?: Record<string, number>;
183
+ percentage?: number;
184
+ keyframes?: Array<{ percentage: number; properties: Record<string, number> }>;
185
+ };
186
+ if (mutation.keyframes) {
187
+ for (const frame of mutation.keyframes) if (frame.percentage === 0) take(frame.properties);
188
+ continue;
189
+ }
190
+ if (mutation.percentage != null && mutation.percentage !== 0) continue;
191
+ take(mutation.properties);
192
+ }
193
+ return found;
194
+ }
195
+
196
+ async function runCase(testCase: ResizeCase) {
197
+ const rotation = testCase.rotation ?? 0;
198
+ const el = createResizeElement(testCase);
199
+ // What the gesture stamps at drag start, and the draft it leaves applied.
200
+ el.setAttribute("data-hf-drag-gsap-base-x", `${testCase.base.x}`);
201
+ el.setAttribute("data-hf-drag-gsap-base-y", `${testCase.base.y}`);
202
+ el.setAttribute("data-hf-studio-box-size", "true");
203
+ el.style.width = `${testCase.drop.w}px`;
204
+ el.style.height = `${testCase.drop.h}px`;
205
+ document.body.append(el);
206
+
207
+ // The live pose the intercept reads and mutates.
208
+ const live: Pose = {
209
+ box: { ...testCase.box },
210
+ pos: { x: testCase.drop.x, y: testCase.drop.y },
211
+ scale: { ...testCase.liveScale },
212
+ };
213
+ el.getBoundingClientRect = () => {
214
+ // The draft's inline box while it is applied, the real one once cleared.
215
+ const w = Number.parseFloat(el.style.width) || live.box.w;
216
+ const h = Number.parseFloat(el.style.height) || live.box.h;
217
+ const rect = renderRect({ ...live, box: { w, h } }, rotation);
218
+ return { ...rect, width: rect.w, height: rect.h } as unknown as DOMRect;
219
+ };
220
+ const gsapStub = {
221
+ set: (_target: Element, vars: Record<string, number>) => {
222
+ if (vars.x != null) live.pos.x = vars.x;
223
+ if (vars.y != null) live.pos.y = vars.y;
224
+ if (vars.scaleX != null) live.scale.x = vars.scaleX;
225
+ if (vars.scaleY != null) live.scale.y = vars.scaleY;
226
+ },
227
+ getProperty: (_target: Element, prop: string) =>
228
+ ({
229
+ scaleX: live.scale.x,
230
+ scaleY: live.scale.y,
231
+ x: live.pos.x,
232
+ y: live.pos.y,
233
+ rotation,
234
+ })[prop] ?? 0,
235
+ };
236
+ Object.assign(window, { gsap: gsapStub });
237
+ const iframe = {
238
+ contentWindow: { gsap: gsapStub, __timelines: { main: { getChildren: () => [] } } },
239
+ contentDocument: document,
240
+ } as unknown as HTMLIFrameElement;
241
+
242
+ const dropPoint = el.getBoundingClientRect();
243
+ const position = positionAnimation(testCase.positionWrite ?? "static-set", testCase.base);
244
+ const animations = [scaleTween(!!testCase.longhandTween), ...(position ? [position] : [])];
245
+ const commitMutation = vi.fn();
246
+ usePlayerStore.setState({ currentTime: 0 });
247
+
248
+ await tryGsapResizeIntercept(
249
+ { id: "clip", selector: "#clip", element: el } as DomEditSelection,
250
+ { width: testCase.drop.w, height: testCase.drop.h },
251
+ animations,
252
+ iframe,
253
+ commitMutation,
254
+ async () => animations,
255
+ );
256
+
257
+ // Re-render what the file now says: the committed scale, the persisted
258
+ // position (or the pre-gesture one when nothing was written), the real box.
259
+ const scale = persistedScale(commitMutation.mock.calls) ?? testCase.liveScale;
260
+ const writes = persistedPositions(commitMutation.mock.calls);
261
+ const last = writes.at(-1);
262
+ const settled: Pose = {
263
+ box: testCase.box,
264
+ pos: { x: last?.x ?? testCase.base.x, y: last?.y ?? testCase.base.y },
265
+ scale,
266
+ };
267
+ return {
268
+ dropPoint: { x: dropPoint.x, y: dropPoint.y, w: dropPoint.width, h: dropPoint.height },
269
+ settled: renderRect(settled, rotation),
270
+ writes,
271
+ };
272
+ }
273
+
274
+ const CASES: ResizeCase[] = [
275
+ {
276
+ name: "shrinks a stylesheet-sized element",
277
+ box: { w: 630, h: 252 },
278
+ base: { x: 489, y: 195 },
279
+ liveScale: { x: 1.648, y: 1.648 },
280
+ drop: { w: 320, h: 128, x: 587, y: 235 },
281
+ },
282
+ {
283
+ name: "grows a stylesheet-sized element",
284
+ box: { w: 630, h: 252 },
285
+ base: { x: 489, y: 195 },
286
+ liveScale: { x: 0.837, y: 0.837 },
287
+ drop: { w: 1319, h: 527, x: -67, y: -26 },
288
+ },
289
+ {
290
+ name: "resizes an element for the first time (no scale of its own yet)",
291
+ box: { w: 630, h: 252 },
292
+ base: { x: 489, y: 195 },
293
+ liveScale: { x: 1, y: 1 },
294
+ drop: { w: 900, h: 360, x: 300, y: 100 },
295
+ },
296
+ {
297
+ name: "resizes a rotated element",
298
+ box: { w: 630, h: 200 },
299
+ base: { x: 329, y: 129 },
300
+ liveScale: { x: 0.94, y: 0.939 },
301
+ rotation: -8,
302
+ longhandTween: true,
303
+ drop: { w: 350, h: 111, x: 609, y: 219 },
304
+ },
305
+ {
306
+ name: "resizes a steeply rotated element",
307
+ box: { w: 630, h: 252 },
308
+ base: { x: 17, y: -69 },
309
+ liveScale: { x: 1.192, y: 1.2 },
310
+ rotation: -47,
311
+ longhandTween: true,
312
+ drop: { w: 491, h: 196, x: 120, y: 40 },
313
+ },
314
+ {
315
+ name: "takes a non-uniform drag",
316
+ box: { w: 630, h: 252 },
317
+ base: { x: 489, y: 195 },
318
+ liveScale: { x: 1, y: 1 },
319
+ longhandTween: true,
320
+ drop: { w: 1200, h: 300, x: -80, y: 60 },
321
+ },
322
+ {
323
+ name: "shrinks almost to nothing",
324
+ box: { w: 630, h: 252 },
325
+ base: { x: 489, y: 195 },
326
+ liveScale: { x: 1, y: 1 },
327
+ drop: { w: 13, h: 5, x: 800, y: 320 },
328
+ },
329
+ {
330
+ name: "resizes an inline-sized element",
331
+ box: { w: 500, h: 252 },
332
+ base: { x: 100, y: 100 },
333
+ liveScale: { x: 0.5, y: 0.5 },
334
+ inlineSized: true,
335
+ drop: { w: 700, h: 353, x: -40, y: -30 },
336
+ },
337
+ {
338
+ name: "resizes an element with no position write at all",
339
+ box: { w: 630, h: 252 },
340
+ base: { x: 0, y: 0 },
341
+ liveScale: { x: 1, y: 1 },
342
+ positionWrite: "none",
343
+ drop: { w: 900, h: 360, x: 140, y: 55 },
344
+ },
345
+ {
346
+ name: "resizes an element whose position is animated",
347
+ box: { w: 630, h: 252 },
348
+ base: { x: 489, y: 195 },
349
+ liveScale: { x: 1.648, y: 1.648 },
350
+ positionWrite: "keyframed-tween",
351
+ drop: { w: 320, h: 128, x: 587, y: 235 },
352
+ },
353
+ ];
354
+
355
+ describe("a scale resize lands the element on the drop point", () => {
356
+ for (const testCase of CASES) {
357
+ it(testCase.name, async () => {
358
+ const { dropPoint, settled } = await runCase(testCase);
359
+ // 1px: the commit rounds position to whole pixels and scale to 3dp.
360
+ expect(settled.x).toBeCloseTo(dropPoint.x, 0);
361
+ expect(settled.y).toBeCloseTo(dropPoint.y, 0);
362
+ expect(settled.w).toBeCloseTo(dropPoint.w, 0);
363
+ expect(settled.h).toBeCloseTo(dropPoint.h, 0);
364
+ });
365
+ }
366
+ });
367
+
368
+ /**
369
+ * Two drags in a row on the same element. The first bug in this class only
370
+ * showed on the second one, because the wrong value the first wrote then
371
+ * counted as the element's live pose.
372
+ */
373
+ it("holds the drop point across a second drag", async () => {
374
+ const first = await runCase({
375
+ name: "first",
376
+ box: { w: 630, h: 252 },
377
+ base: { x: 489, y: 195 },
378
+ liveScale: { x: 1, y: 1 },
379
+ drop: { w: 900, h: 360, x: 300, y: 100 },
380
+ });
381
+ const settledPos = first.writes.at(-1);
382
+ document.body.innerHTML = "";
383
+ const second = await runCase({
384
+ name: "second",
385
+ box: { w: 630, h: 252 },
386
+ base: { x: settledPos?.x ?? 489, y: settledPos?.y ?? 195 },
387
+ // What the first drag committed: 900/630.
388
+ liveScale: { x: 1.429, y: 1.429 },
389
+ drop: { w: 420, h: 168, x: 640, y: 260 },
390
+ });
391
+ expect(second.settled.x).toBeCloseTo(second.dropPoint.x, 0);
392
+ expect(second.settled.y).toBeCloseTo(second.dropPoint.y, 0);
393
+ expect(second.settled.w).toBeCloseTo(second.dropPoint.w, 0);
394
+ });