fix(studio): measure the resize correction from the pre-gesture position

A scale resize measured its drop-point correction while the gesture's own
translation was still applied, but the position commit adds that correction
onto the element's PRE-gesture position, which it reads from the gesture's
base attributes. The two disagreed by the whole drag distance, so the commit
persisted a position a drag-length from where the element was dropped: it
held the drop point for one frame and then slid off.

Move the element back to that base before measuring, so the residual and the
commit share one origin. For an element whose position is a static hold that
usually means no correction at all, which is the right answer: scaling about
the centre already leaves it on the drop point.
This commit is contained in:
Miguel Angel Simon Sierra
2026-08-06 17:05:37 -07:00
parent b18fd62e0e
commit ee5ae9619c
2 changed files with 127 additions and 8 deletions
@@ -381,3 +381,102 @@ it("does not mix the scale shorthand into a tween that speaks longhands", async
const resized = frames.find((frame) => frame.properties.scaleX != null);
expect(resized?.properties.scaleX).toBeCloseTo(0.61, 1);
});
/**
* The bug: a scale resize measured its drop-point correction while the
* gesture's own translation was still applied, but the position commit adds
* that correction onto the element's PRE-gesture position (it reads the
* gesture's base attributes). The two disagreed by the whole drag distance, so
* every scale resize of a statically positioned element persisted a position a
* drag-length away from where it was dropped — the element held still for one
* frame and then slid off.
*
* The fixture models the geometry the browser reported: a 630x252 element
* dragged from x=432 to x=587 with its box drafted down to 320x128, dropped at
* a committed scale of 0.837. Scaling about the centre puts it back on the drop
* point at its pre-gesture position, so the correct persisted correction is
* NONE.
*/
it("does not move a statically positioned element when a scale resize lands", async () => {
document.body.innerHTML = "";
const el = document.createElement("div");
el.id = "clip";
el.setAttribute("data-hf-studio-original-box-width", "630");
el.setAttribute("data-hf-studio-original-box-height", "252");
// The gesture's base pose — where the commit puts the element back, since a
// scale resize never persists the drag translation.
el.setAttribute("data-hf-drag-gsap-base-x", "432");
el.setAttribute("data-hf-drag-gsap-base-y", "173");
// The draft the gesture left applied: a smaller box at the dragged position.
el.setAttribute("data-hf-studio-box-size", "true");
el.setAttribute("data-hf-studio-original-width", "");
el.setAttribute("data-hf-studio-original-height", "");
el.style.width = "320px";
el.style.height = "128px";
document.body.append(el);
const pos = { x: 587, y: 235 };
const scale = { x: 1.648, y: 1.648 };
const [LEFT, TOP] = [120, 520];
el.getBoundingClientRect = () => {
const cssW = Number.parseFloat(el.style.width) || 630;
const cssH = Number.parseFloat(el.style.height) || 252;
const [w, h] = [cssW * scale.x, cssH * scale.y];
// GSAP scales about the element centre, so the box grows around it.
return {
x: LEFT + pos.x + cssW / 2 - w / 2,
y: TOP + pos.y + cssH / 2 - h / 2,
width: w,
height: h,
} as DOMRect;
};
const gsapStub = {
set: (_target: Element, vars: Record<string, number>) => {
if (vars.x != null) pos.x = vars.x;
if (vars.y != null) pos.y = vars.y;
if (vars.scaleX != null) scale.x = vars.scaleX;
if (vars.scaleY != null) scale.y = vars.scaleY;
},
getProperty: (_target: Element, prop: string) =>
({ scaleX: scale.x, scaleY: scale.y, x: pos.x, y: pos.y })[prop] ?? 0,
};
Object.assign(window, { gsap: gsapStub });
const iframe = {
contentWindow: { gsap: gsapStub, __timelines: {} },
contentDocument: document,
} as unknown as HTMLIFrameElement;
const positionHold = {
id: "#clip-set-0-position",
targetSelector: "#clip",
propertyGroup: "position",
method: "set",
properties: { x: 432, y: 173 },
position: 0,
resolvedStart: 0,
duration: 0,
global: true,
} as unknown as GsapAnimation;
const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
usePlayerStore.setState({ currentTime: 0.5 });
const commitMutation = vi.fn();
await tryGsapResizeIntercept(
selection,
{ width: 320, height: 128 },
[keyframedScaleFixture(), positionHold],
iframe,
commitMutation,
async () => [keyframedScaleFixture(), positionHold],
);
const positionWrites = commitMutation.mock.calls
.map((call) => call[1] as { properties?: Record<string, number> })
.filter((mutation) => mutation.properties?.x != null || mutation.properties?.y != null);
// Either it left the position alone, or it rewrote the same value.
for (const write of positionWrites) {
expect(write.properties?.x).toBe(432);
expect(write.properties?.y).toBe(173);
}
// And the live element ends on the drop point, not a drag away from it.
expect(el.getBoundingClientRect().x).toBeCloseTo(603.3, 0);
});
@@ -14,6 +14,7 @@ import {
} from "../components/editor/manualEditsTypes";
import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
import { usePlayerStore } from "../player/store/playerStore";
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
import {
commitStaticGsapPosition,
@@ -25,6 +26,7 @@ import {
materializeIfDynamic,
} from "./gsapDragCommit";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
@@ -303,26 +305,44 @@ export async function tryGsapResizeIntercept(
if (committedScale) {
setElementGsapScale(scaleDraftEl, committedScale.x, committedScale.y);
}
// Measure from the pre-gesture position, not the draft one.
//
// The resize draft translates the element to keep the dragged corner under
// the cursor, but the scale route never persists that translation — the
// element renders back at its pre-gesture position as soon as the commit
// lands. Measuring while the draft translation was still applied made the
// residual carry the whole drag distance, and the position commit then
// composed that residual onto the pre-gesture base (it reads the gesture's
// own base attributes, not the live value), so the element landed a full
// drag away from the drop point on every scale resize.
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
selection.element,
{ x: 0, y: 0 },
gsapPos,
);
const base = { x: baseGsapX, y: baseGsapY };
setElementGsapPosition(scaleDraftEl, base.x, base.y);
const post = scaleDraftEl.getBoundingClientRect();
const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return;
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
logResize("scale-finalize", { skipped: "already-on-drop-point", residual });
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
return;
}
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
// The ONE corrected position — rounded once so the live runtime and the
// persisted file agree exactly (commitStaticGsapPosition composes the same
// rounded value from this delta).
const corrected = {
x: Math.round(gsapPos.x + residual.x),
y: Math.round(gsapPos.y + residual.y),
x: Math.round(base.x + residual.x),
y: Math.round(base.y + residual.y),
};
logResize("scale-finalize", {
dropPoint: scaleDraftDropPoint,
post: { x: post.x, y: post.y },
residual,
gsapPos,
base,
corrected,
});
// Correct the LIVE runtime NOW, synchronously: the soft reload above just
@@ -339,8 +359,8 @@ export async function tryGsapResizeIntercept(
? await fetchFallbackAnimations()
: (resolved?.animations ?? animations);
// Delta chosen so the drag-path math composes back to exactly `corrected`
// (no drag scratch attrs exist during a resize, so base = gsapPos).
const delta = { x: corrected.x - gsapPos.x, y: corrected.y - gsapPos.y };
// — it adds this onto the same base the measurement above used.
const delta = { x: corrected.x - base.x, y: corrected.y - base.y };
// An element whose position is animated needs the correction written into
// that animation, at the playhead, or the tween renders its own value a
// frame later and the element leaves the drop point anyway. This used to
@@ -355,14 +375,14 @@ export async function tryGsapResizeIntercept(
);
if (positionTween) {
logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
await commitGsapPositionFromDrag(selection, positionTween, delta, gsapPos, iframe, selector, {
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, selector, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});
return;
}
const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
await commitStaticGsapPosition(selection, delta, gsapPos, selector, existingSet, {
await commitStaticGsapPosition(selection, delta, base, selector, existingSet, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});