@hyperframes/studio 0.7.96 → 0.7.98

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.
@@ -284,3 +284,201 @@ it("non-uniform drag commits scaleX/scaleY longhands", async () => {
284
284
  expect(serialized).toContain("scaleX");
285
285
  expect(serialized).toContain("scaleY");
286
286
  });
287
+
288
+ /**
289
+ * The bug: the original box size was read only from the element's INLINE
290
+ * width, and a composition sizes its elements from the stylesheet. With no
291
+ * inline width the code fell back to a hardcoded 200, so the committed scale
292
+ * came out `real / 200` times too large. Dropping a 630px chip at 2391px wide
293
+ * left it rendering at 7532px, over three times where it was dropped, and the
294
+ * next drag compounded it.
295
+ */
296
+ // fallow-ignore-next-line code-duplication
297
+ it("scales from the element's real box, not a hardcoded fallback", async () => {
298
+ const el = document.createElement("div");
299
+ el.id = "clip";
300
+ // Sized by a stylesheet, so it carries no inline width, and the draft
301
+ // recorded the box it measured instead.
302
+ el.setAttribute("data-hf-studio-original-box-width", "630");
303
+ el.setAttribute("data-hf-studio-original-box-height", "252");
304
+ document.body.append(el);
305
+ const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
306
+ const commitMutation = vi.fn();
307
+
308
+ await tryGsapResizeIntercept(
309
+ selection,
310
+ { width: 1260, height: 504 },
311
+ [keyframedScaleFixture()],
312
+ fakeIframe(el, { scaleX: 1, scaleY: 1 }),
313
+ commitMutation,
314
+ );
315
+
316
+ type Mutation = {
317
+ properties?: Record<string, number>;
318
+ keyframes?: Array<{ percentage: number; properties: Record<string, number> }>;
319
+ };
320
+ const committed = commitMutation.mock.calls
321
+ .map((call) => call[1] as Mutation)
322
+ .flatMap((mutation) => [
323
+ mutation.properties,
324
+ ...(mutation.keyframes ?? []).map((frame) => frame.properties),
325
+ ])
326
+ .filter((properties): properties is Record<string, number> => properties != null)
327
+ .find((properties) => properties.scale != null || properties.scaleX != null);
328
+
329
+ // Dropped at twice the element's own size, so the scale is about 2. The
330
+ // number that matters is that it is not the 6.3 which 1260/200 produced.
331
+ const scale = committed?.scale ?? committed?.scaleX ?? 0;
332
+ expect(scale).toBeCloseTo(2, 1);
333
+ expect(scale).toBeLessThan(3);
334
+ });
335
+
336
+ /**
337
+ * The bug: a uniform drag committed the `scale` shorthand into a tween whose
338
+ * keyframes already stated `scaleX`/`scaleY`. GSAP animates each property name
339
+ * independently, so the keyframe ran as `{ scaleX: 1, scaleY: 1, scale: 0.61 }`
340
+ * and the longhands won. The resize computed the right number, wrote it, and
341
+ * the element snapped straight back to its old size on release.
342
+ */
343
+ // fallow-ignore-next-line code-duplication
344
+ it("does not mix the scale shorthand into a tween that speaks longhands", async () => {
345
+ const el = document.createElement("div");
346
+ el.id = "clip";
347
+ el.setAttribute("data-hf-studio-original-box-width", "630");
348
+ el.setAttribute("data-hf-studio-original-box-height", "252");
349
+ document.body.append(el);
350
+ const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
351
+ const longhandTween = {
352
+ ...scaleFromTween(),
353
+ keyframes: {
354
+ keyframes: [
355
+ { percentage: 0, properties: { scaleX: 1, scaleY: 1 } },
356
+ { percentage: 100, properties: { scaleX: 1.15, scaleY: 1.15 } },
357
+ ],
358
+ },
359
+ } as unknown as GsapAnimation;
360
+ const commitMutation = vi.fn();
361
+
362
+ // A uniform drop, so the old code took the shorthand branch and wrote
363
+ // `scale` into keyframes that already stated the longhands.
364
+ await tryGsapResizeIntercept(
365
+ selection,
366
+ { width: 384, height: 216 },
367
+ [longhandTween],
368
+ fakeIframe(el, { scaleX: 1, scaleY: 1 }),
369
+ commitMutation,
370
+ );
371
+
372
+ const frames = commitMutation.mock.calls
373
+ .map((call) => call[1] as { keyframes?: Array<{ properties: Record<string, number> }> })
374
+ .flatMap((mutation) => mutation.keyframes ?? []);
375
+ expect(frames.length).toBeGreaterThan(0);
376
+ for (const frame of frames) {
377
+ const names = Object.keys(frame.properties);
378
+ const hasShorthand = names.includes("scale");
379
+ const hasLonghand = names.includes("scaleX") || names.includes("scaleY");
380
+ expect(hasShorthand && hasLonghand).toBe(false);
381
+ }
382
+ // And the resize still lands: 384/630 is about 0.61.
383
+ const resized = frames.find((frame) => frame.properties.scaleX != null);
384
+ expect(resized?.properties.scaleX).toBeCloseTo(0.61, 1);
385
+ });
386
+
387
+ /**
388
+ * The bug: a scale resize measured its drop-point correction while the
389
+ * gesture's own translation was still applied, but the position commit adds
390
+ * that correction onto the element's PRE-gesture position (it reads the
391
+ * gesture's base attributes). The two disagreed by the whole drag distance, so
392
+ * every scale resize of a statically positioned element persisted a position a
393
+ * drag-length away from where it was dropped — the element held still for one
394
+ * frame and then slid off.
395
+ *
396
+ * The fixture models the geometry the browser reported: a 630x252 element
397
+ * dragged from x=432 to x=587 with its box drafted down to 320x128, dropped at
398
+ * a committed scale of 0.837. Scaling about the centre puts it back on the drop
399
+ * point at its pre-gesture position, so the correct persisted correction is
400
+ * NONE.
401
+ */
402
+ it("does not move a statically positioned element when a scale resize lands", async () => {
403
+ document.body.innerHTML = "";
404
+ const el = document.createElement("div");
405
+ el.id = "clip";
406
+ el.setAttribute("data-hf-studio-original-box-width", "630");
407
+ el.setAttribute("data-hf-studio-original-box-height", "252");
408
+ // The gesture's base pose — where the commit puts the element back, since a
409
+ // scale resize never persists the drag translation.
410
+ el.setAttribute("data-hf-drag-gsap-base-x", "432");
411
+ el.setAttribute("data-hf-drag-gsap-base-y", "173");
412
+ // The draft the gesture left applied: a smaller box at the dragged position.
413
+ el.setAttribute("data-hf-studio-box-size", "true");
414
+ el.setAttribute("data-hf-studio-original-width", "");
415
+ el.setAttribute("data-hf-studio-original-height", "");
416
+ el.style.width = "320px";
417
+ el.style.height = "128px";
418
+ document.body.append(el);
419
+
420
+ const pos = { x: 587, y: 235 };
421
+ const scale = { x: 1.648, y: 1.648 };
422
+ const [LEFT, TOP] = [120, 520];
423
+ el.getBoundingClientRect = () => {
424
+ const cssW = Number.parseFloat(el.style.width) || 630;
425
+ const cssH = Number.parseFloat(el.style.height) || 252;
426
+ const [w, h] = [cssW * scale.x, cssH * scale.y];
427
+ // GSAP scales about the element centre, so the box grows around it.
428
+ return {
429
+ x: LEFT + pos.x + cssW / 2 - w / 2,
430
+ y: TOP + pos.y + cssH / 2 - h / 2,
431
+ width: w,
432
+ height: h,
433
+ } as DOMRect;
434
+ };
435
+ const gsapStub = {
436
+ set: (_target: Element, vars: Record<string, number>) => {
437
+ if (vars.x != null) pos.x = vars.x;
438
+ if (vars.y != null) pos.y = vars.y;
439
+ if (vars.scaleX != null) scale.x = vars.scaleX;
440
+ if (vars.scaleY != null) scale.y = vars.scaleY;
441
+ },
442
+ getProperty: (_target: Element, prop: string) =>
443
+ ({ scaleX: scale.x, scaleY: scale.y, x: pos.x, y: pos.y })[prop] ?? 0,
444
+ };
445
+ Object.assign(window, { gsap: gsapStub });
446
+ const iframe = {
447
+ contentWindow: { gsap: gsapStub, __timelines: {} },
448
+ contentDocument: document,
449
+ } as unknown as HTMLIFrameElement;
450
+ const positionHold = {
451
+ id: "#clip-set-0-position",
452
+ targetSelector: "#clip",
453
+ propertyGroup: "position",
454
+ method: "set",
455
+ properties: { x: 432, y: 173 },
456
+ position: 0,
457
+ resolvedStart: 0,
458
+ duration: 0,
459
+ global: true,
460
+ } as unknown as GsapAnimation;
461
+ const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
462
+ usePlayerStore.setState({ currentTime: 0.5 });
463
+ const commitMutation = vi.fn();
464
+
465
+ await tryGsapResizeIntercept(
466
+ selection,
467
+ { width: 320, height: 128 },
468
+ [keyframedScaleFixture(), positionHold],
469
+ iframe,
470
+ commitMutation,
471
+ async () => [keyframedScaleFixture(), positionHold],
472
+ );
473
+
474
+ const positionWrites = commitMutation.mock.calls
475
+ .map((call) => call[1] as { properties?: Record<string, number> })
476
+ .filter((mutation) => mutation.properties?.x != null || mutation.properties?.y != null);
477
+ // Either it left the position alone, or it rewrote the same value.
478
+ for (const write of positionWrites) {
479
+ expect(write.properties?.x).toBe(432);
480
+ expect(write.properties?.y).toBe(173);
481
+ }
482
+ // And the live element ends on the drop point, not a drag away from it.
483
+ expect(el.getBoundingClientRect().x).toBeCloseTo(603.3, 0);
484
+ });
@@ -8,8 +8,13 @@
8
8
  import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
9
9
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
10
  import { clearStudioBoxSize } from "../components/editor/manualEdits";
11
- import { setElementGsapPosition } from "../utils/elementGsap";
11
+ import {
12
+ STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
13
+ STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
14
+ } from "../components/editor/manualEditsTypes";
15
+ import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
12
16
  import { usePlayerStore } from "../player/store/playerStore";
17
+ import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
13
18
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
14
19
  import {
15
20
  commitStaticGsapPosition,
@@ -21,13 +26,14 @@ import {
21
26
  materializeIfDynamic,
22
27
  } from "./gsapDragCommit";
23
28
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
29
+ import { computeDraggedGsapPosition } from "./draggedGsapPosition";
24
30
  import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
25
31
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
32
+ import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
26
33
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
27
34
  import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
28
35
  import { roundTo3 } from "../utils/rounding";
29
- import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge";
30
- import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
36
+ import { resolveGroupTween } from "./gsapRuntimeBridge";
31
37
  import { logResize } from "../utils/resizeDebug";
32
38
  import {
33
39
  animationWritesAnyProperty,
@@ -49,6 +55,42 @@ function synthesizeIdentityProps(
49
55
  return id;
50
56
  }
51
57
 
58
+ /**
59
+ * The element's box before the resize draft ran, in CSS pixels.
60
+ *
61
+ * Prefers the measurement the draft recorded. Falls back to the inline style it
62
+ * saved for restoring, which is a real value for the elements that carry one,
63
+ * and null when neither says anything.
64
+ */
65
+ function originalBoxSize(
66
+ el: HTMLElement | null,
67
+ measuredAttr: string,
68
+ inlineProperty: "width" | "height",
69
+ ): number | null {
70
+ const measured = Number.parseFloat(el?.getAttribute(measuredAttr) ?? "");
71
+ if (Number.isFinite(measured) && measured > 0) return measured;
72
+ const inline = Number.parseFloat(
73
+ el?.getAttribute(`data-hf-studio-original-${inlineProperty}`) ?? "",
74
+ );
75
+ return Number.isFinite(inline) && inline > 0 ? inline : null;
76
+ }
77
+
78
+ /**
79
+ * Whether this tween already states scale as `scaleX`/`scaleY`.
80
+ *
81
+ * Both forms are legal, and either alone is fine. A tween holding both is not:
82
+ * GSAP animates each property name independently, so the longhands run
83
+ * alongside the shorthand and win, which silently discards whatever the
84
+ * shorthand was set to.
85
+ */
86
+ function tweenUsesScaleLonghands(anim: GsapAnimation | null): boolean {
87
+ const isLonghand = (name: string) => name === "scaleX" || name === "scaleY";
88
+ const inKeyframes = (anim?.keyframes?.keyframes ?? []).some((frame) =>
89
+ Object.keys(frame.properties ?? {}).some(isLonghand),
90
+ );
91
+ return inKeyframes || Object.keys(anim?.properties ?? {}).some(isLonghand);
92
+ }
93
+
52
94
  // ── Resize intercept ──────────────────────────────────────────────────────
53
95
 
54
96
  // fallow-ignore-next-line complexity
@@ -155,17 +197,26 @@ export async function tryGsapResizeIntercept(
155
197
  let resizeProps: Record<string, number>;
156
198
  let scaleDraftEl: HTMLElement | null = null;
157
199
  let scaleDraftDropPoint: { x: number; y: number } | null = null;
200
+ /** The scale this commit is putting on the element, for the finalize step. */
201
+ let committedScale: { x: number; y: number } | null = null;
158
202
  let nonUniformScale = false;
203
+ /** Whether this commit writes scaleX/scaleY rather than the `scale` shorthand. */
204
+ let useScaleLonghands = false;
159
205
  if (resizeGroup === "scale") {
160
206
  // Iframe-realm element — instanceof HTMLElement fails across realms; the
161
207
  // selector targets composition elements, and every use below is duck-typed.
162
208
  const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null;
163
209
  // The resize draft modifies el.style.width/height, so read the ORIGINAL
164
210
  // dimensions saved by the draft system before it ran.
165
- const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? "");
166
- const origH = Number.parseFloat(el?.getAttribute("data-hf-studio-original-height") ?? "");
167
- const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200;
168
- const cssH = Number.isFinite(origH) && origH > 0 ? origH : cssW;
211
+ //
212
+ // The measured box first, then the inline one. The inline attributes exist
213
+ // to restore an inline style and are empty for anything sized by a
214
+ // stylesheet, which is how compositions are written, so reading them alone
215
+ // sent almost every element to the fallback below: a 630px chip scaled by
216
+ // 630/200, landing over three times the size it was dropped at, and worse
217
+ // on the next drag because the wrong scale then counted as its live one.
218
+ const cssW = originalBoxSize(el, STUDIO_ORIGINAL_BOX_WIDTH_ATTR, "width") ?? 200;
219
+ const cssH = originalBoxSize(el, STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, "height") ?? cssW;
169
220
  // `size` is the draft's CSS box; on screen it is multiplied by the element's
170
221
  // LIVE scale (the draft divides the cursor delta by it — see
171
222
  // resolveDomEditResizeGesture). The committed keyframe REPLACES that live
@@ -181,8 +232,18 @@ export async function tryGsapResizeIntercept(
181
232
  // can't represent it — committing width-derived scale used to snap the
182
233
  // height at drop. Commit scaleX/scaleY longhands instead; keep the uniform
183
234
  // shorthand when the two agree (aspect-true drags, shift-drags).
235
+ //
236
+ // Unless the tween already speaks longhands, in which case a uniform drag
237
+ // has to as well. GSAP animates each property name on its own, so a
238
+ // keyframe holding `{ scaleX: 1, scaleY: 1, scale: 0.61 }` runs all three
239
+ // and the longhands win: the resize commits correctly and then does
240
+ // nothing, and the element snaps back to its old size on release. The
241
+ // tween never mixes the two forms in either direction.
184
242
  nonUniformScale = Math.abs(newScaleX - newScaleY) > 0.01;
185
- resizeProps = nonUniformScale ? { scaleX: newScaleX, scaleY: newScaleY } : { scale: newScaleX };
243
+ useScaleLonghands = nonUniformScale || tweenUsesScaleLonghands(anim);
244
+ resizeProps = useScaleLonghands
245
+ ? { scaleX: newScaleX, scaleY: newScaleY }
246
+ : { scale: newScaleX };
186
247
  logResize("intercept-route", {
187
248
  route: "scale-tween",
188
249
  cssW,
@@ -194,6 +255,13 @@ export async function tryGsapResizeIntercept(
194
255
  nonUniformScale,
195
256
  });
196
257
  scaleDraftEl = el;
258
+ // What the commit ACTUALLY writes, which is what the finalize step below
259
+ // has to measure against. A near-uniform drag collapses to the shorthand,
260
+ // so taking the per-axis pair here measured the element at a scaleY the
261
+ // file never gets and tilted the correction by the difference.
262
+ committedScale = useScaleLonghands
263
+ ? { x: newScaleX, y: newScaleY }
264
+ : { x: newScaleX, y: newScaleX };
197
265
  // Where the user DROPPED the box: the draft (anchor-pinned to the
198
266
  // gesture-start top-left) is still applied here, so this rect is exactly
199
267
  // what the preview showed at release. The committed scale renders around
@@ -226,35 +294,61 @@ export async function tryGsapResizeIntercept(
226
294
  if (!scaleDraftEl) return;
227
295
  clearStudioBoxSize(scaleDraftEl);
228
296
  if (!scaleDraftDropPoint || !selector) return;
229
- const hasLivePositionTween = hasNonHoldTweenForElement(
230
- iframe,
231
- selector,
232
- undefined,
233
- POSITION_CHANNELS,
234
- );
235
- if (hasLivePositionTween) {
236
- logResize("scale-finalize", { skipped: "live-position-tween" });
237
- return;
297
+ // Put the committed scale on the live element before measuring.
298
+ //
299
+ // This step reads where the commit lands the box and shifts the position
300
+ // hold by the difference. That only works if the commit has actually
301
+ // rendered, and whether it had was luck: on the FIRST resize of an element
302
+ // the timeline had not re-seeked yet, so this measured the element at its
303
+ // natural size, still sitting on the drop point, computed a residual of
304
+ // zero, and skipped the correction entirely. The scale then landed, GSAP
305
+ // rendered it around the element's centre, and the element jumped by the
306
+ // whole drag distance. Elements that had been resized before got a
307
+ // correction only because their PREVIOUS scale made the residual non-zero.
308
+ //
309
+ // Setting it here costs nothing when the commit has already rendered (same
310
+ // value) and makes the measurement below mean what it says either way.
311
+ if (committedScale) {
312
+ setElementGsapScale(scaleDraftEl, committedScale.x, committedScale.y);
238
313
  }
239
- // The scale commit has rendered (instant patch or soft-reload seek) and the
240
- // draft is cleared — this rect is where the element ACTUALLY sits now.
314
+ // Measure from the pre-gesture position, not the draft one.
315
+ //
316
+ // The resize draft translates the element to keep the dragged corner under
317
+ // the cursor, but the scale route never persists that translation — the
318
+ // element renders back at its pre-gesture position as soon as the commit
319
+ // lands. Measuring while the draft translation was still applied made the
320
+ // residual carry the whole drag distance, and the position commit then
321
+ // composed that residual onto the pre-gesture base (it reads the gesture's
322
+ // own base attributes, not the live value), so the element landed a full
323
+ // drag away from the drop point on every scale resize.
324
+ const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
325
+ const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
326
+ selection.element,
327
+ { x: 0, y: 0 },
328
+ gsapPos,
329
+ );
330
+ const base = { x: baseGsapX, y: baseGsapY };
331
+ setElementGsapPosition(scaleDraftEl, base.x, base.y);
241
332
  const post = scaleDraftEl.getBoundingClientRect();
242
333
  const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
243
334
  if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return;
244
- if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) return;
245
- const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
335
+ if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
336
+ logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
337
+ return;
338
+ }
246
339
  // The ONE corrected position — rounded once so the live runtime and the
247
340
  // persisted file agree exactly (commitStaticGsapPosition composes the same
248
341
  // rounded value from this delta).
249
342
  const corrected = {
250
- x: Math.round(gsapPos.x + residual.x),
251
- y: Math.round(gsapPos.y + residual.y),
343
+ x: Math.round(base.x + residual.x),
344
+ y: Math.round(base.y + residual.y),
252
345
  };
253
346
  logResize("scale-finalize", {
254
347
  dropPoint: scaleDraftDropPoint,
255
348
  post: { x: post.x, y: post.y },
256
349
  residual,
257
350
  gsapPos,
351
+ base,
258
352
  corrected,
259
353
  });
260
354
  // Correct the LIVE runtime NOW, synchronously: the soft reload above just
@@ -270,20 +364,34 @@ export async function tryGsapResizeIntercept(
270
364
  const currentAnimations = fetchFallbackAnimations
271
365
  ? await fetchFallbackAnimations()
272
366
  : (resolved?.animations ?? animations);
273
- const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
274
367
  // Delta chosen so the drag-path math composes back to exactly `corrected`
275
- // (no drag scratch attrs exist during a resize, so base = gsapPos).
276
- await commitStaticGsapPosition(
277
- selection,
278
- { x: corrected.x - gsapPos.x, y: corrected.y - gsapPos.y },
279
- gsapPos,
280
- selector,
281
- existingSet,
282
- {
368
+ // it adds this onto the same base the measurement above used.
369
+ const delta = { x: corrected.x - base.x, y: corrected.y - base.y };
370
+ // An element whose position is animated needs the correction written into
371
+ // that animation, at the playhead, or the tween renders its own value a
372
+ // frame later and the element leaves the drop point anyway. This used to
373
+ // stand down here instead, on the grounds that a keyframed path has no
374
+ // single anchor to preserve. It has one: the frame the user is looking at.
375
+ // Writing it is the same thing a drag on the same element does, through
376
+ // the same commit.
377
+ const positionTween = pickClosestToPlayhead(
378
+ currentAnimations.filter(
379
+ (a) => a.propertyGroup === "position" && !isInstantHold(a) && resolveTweenDuration(a) > 0,
380
+ ),
381
+ );
382
+ if (positionTween) {
383
+ logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
384
+ await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, selector, {
283
385
  commitMutation,
284
386
  fetchAnimations: fetchFallbackAnimations,
285
- },
286
- );
387
+ });
388
+ return;
389
+ }
390
+ const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
391
+ await commitStaticGsapPosition(selection, delta, base, selector, existingSet, {
392
+ commitMutation,
393
+ fetchAnimations: fetchFallbackAnimations,
394
+ });
287
395
  };
288
396
 
289
397
  // With auto-keyframe off (#1808), `anim` is already a real (non-"set")
@@ -341,7 +449,7 @@ export async function tryGsapResizeIntercept(
341
449
  // normalizes every keyframe to the longhands. For an in-range resize the
342
450
  // min/max window math below degenerates to the tween's own start/duration,
343
451
  // so timing is unchanged.
344
- if ((outsideRange || nonUniformScale) && ts !== null) {
452
+ if ((outsideRange || useScaleLonghands) && ts !== null) {
345
453
  // For flat tweens, synthesize the keyframes from the tween's properties
346
454
  const kfs =
347
455
  anim.keyframes?.keyframes ??
@@ -24,6 +24,20 @@ export function setElementGsapPosition(element: HTMLElement, x: number, y: numbe
24
24
  return true;
25
25
  }
26
26
 
27
+ /**
28
+ * Set the element's GSAP scale. Returns false when no runtime is reachable.
29
+ *
30
+ * Used to make the element show a scale that has been committed but not yet
31
+ * re-rendered by the timeline, so measuring it afterwards reports where the
32
+ * commit actually puts it rather than where it happened to be mid-flight.
33
+ */
34
+ export function setElementGsapScale(element: HTMLElement, x: number, y: number): boolean {
35
+ const gsap = gsapOf(element);
36
+ if (!gsap?.set) return false;
37
+ gsap.set(element, { scaleX: x, scaleY: y });
38
+ return true;
39
+ }
40
+
27
41
  /** The element's GSAP numeric property, or null when unreadable. */
28
42
  export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
29
43
  const value = Number(gsapOf(element)?.getProperty?.(element, prop));