fix(studio): pin the drop point on a first resize

The finalize step measures where the committed scale put the box and
shifts the position hold by the difference. Whether the commit had
actually rendered when it measured was luck: on a first resize the
timeline had not re-seeked, so it measured the element at its natural
size still sitting on the drop point, saw no residual, and skipped the
correction. The scale then landed, GSAP rendered it about the element's
centre, and the element jumped by the whole drag distance. Elements
resized before got a correction only because their previous scale made
the residual non-zero by accident.

The committed scale is now applied to the live element before measuring,
so the measurement means what its comment says either way, and a skipped
correction is logged rather than silent.

Confirmed against a real session: a first resize of a 630px chip now
reports residual -109.93 and lands on the drop point, where it previously
logged no scale-finalize at all.
This commit is contained in:
Miguel Angel Simon Sierra
2026-08-06 16:34:11 -07:00
parent cdd3dd0565
commit cfdb948330
2 changed files with 69 additions and 6 deletions
@@ -12,7 +12,7 @@ import {
STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
} from "../components/editor/manualEditsTypes";
import { setElementGsapPosition } from "../utils/elementGsap";
import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
import { usePlayerStore } from "../player/store/playerStore";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
import {
@@ -68,6 +68,22 @@ function originalBoxSize(
return Number.isFinite(inline) && inline > 0 ? inline : null;
}
/**
* Whether this tween already states scale as `scaleX`/`scaleY`.
*
* Both forms are legal, and either alone is fine. A tween holding both is not:
* GSAP animates each property name independently, so the longhands run
* alongside the shorthand and win, which silently discards whatever the
* shorthand was set to.
*/
function tweenUsesScaleLonghands(anim: GsapAnimation | null): boolean {
const isLonghand = (name: string) => name === "scaleX" || name === "scaleY";
const inKeyframes = (anim?.keyframes?.keyframes ?? []).some((frame) =>
Object.keys(frame.properties ?? {}).some(isLonghand),
);
return inKeyframes || Object.keys(anim?.properties ?? {}).some(isLonghand);
}
// ── Resize intercept ──────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
@@ -151,7 +167,11 @@ export async function tryGsapResizeIntercept(
let resizeProps: Record<string, number>;
let scaleDraftEl: HTMLElement | null = null;
let scaleDraftDropPoint: { x: number; y: number } | null = null;
/** The scale this commit is putting on the element, for the finalize step. */
let committedScale: { x: number; y: number } | null = null;
let nonUniformScale = false;
/** Whether this commit writes scaleX/scaleY rather than the `scale` shorthand. */
let useScaleLonghands = false;
if (resizeGroup === "scale") {
// Iframe-realm element — instanceof HTMLElement fails across realms; the
// selector targets composition elements, and every use below is duck-typed.
@@ -182,8 +202,18 @@ export async function tryGsapResizeIntercept(
// can't represent it — committing width-derived scale used to snap the
// height at drop. Commit scaleX/scaleY longhands instead; keep the uniform
// shorthand when the two agree (aspect-true drags, shift-drags).
//
// Unless the tween already speaks longhands, in which case a uniform drag
// has to as well. GSAP animates each property name on its own, so a
// keyframe holding `{ scaleX: 1, scaleY: 1, scale: 0.61 }` runs all three
// and the longhands win: the resize commits correctly and then does
// nothing, and the element snaps back to its old size on release. The
// tween never mixes the two forms in either direction.
nonUniformScale = Math.abs(newScaleX - newScaleY) > 0.01;
resizeProps = nonUniformScale ? { scaleX: newScaleX, scaleY: newScaleY } : { scale: newScaleX };
useScaleLonghands = nonUniformScale || tweenUsesScaleLonghands(anim);
resizeProps = useScaleLonghands
? { scaleX: newScaleX, scaleY: newScaleY }
: { scale: newScaleX };
logResize("intercept-route", {
route: "scale-tween",
cssW,
@@ -195,6 +225,7 @@ export async function tryGsapResizeIntercept(
nonUniformScale,
});
scaleDraftEl = el;
committedScale = { x: newScaleX, y: newScaleY };
// Where the user DROPPED the box: the draft (anchor-pinned to the
// gesture-start top-left) is still applied here, so this rect is exactly
// what the preview showed at release. The committed scale renders around
@@ -237,12 +268,30 @@ export async function tryGsapResizeIntercept(
logResize("scale-finalize", { skipped: "live-position-tween" });
return;
}
// The scale commit has rendered (instant patch or soft-reload seek) and the
// draft is cleared — this rect is where the element ACTUALLY sits now.
// Put the committed scale on the live element before measuring.
//
// This step reads where the commit lands the box and shifts the position
// hold by the difference. That only works if the commit has actually
// rendered, and whether it had was luck: on the FIRST resize of an element
// the timeline had not re-seeked yet, so this measured the element at its
// natural size, still sitting on the drop point, computed a residual of
// zero, and skipped the correction entirely. The scale then landed, GSAP
// rendered it around the element's centre, and the element jumped by the
// whole drag distance. Elements that had been resized before got a
// correction only because their PREVIOUS scale made the residual non-zero.
//
// Setting it here costs nothing when the commit has already rendered (same
// value) and makes the measurement below mean what it says either way.
if (committedScale) {
setElementGsapScale(scaleDraftEl, committedScale.x, committedScale.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) return;
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
logResize("scale-finalize", { skipped: "already-on-drop-point", residual });
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
@@ -342,7 +391,7 @@ export async function tryGsapResizeIntercept(
// normalizes every keyframe to the longhands. For an in-range resize the
// min/max window math below degenerates to the tween's own start/duration,
// so timing is unchanged.
if ((outsideRange || nonUniformScale) && ts !== null) {
if ((outsideRange || useScaleLonghands) && ts !== null) {
// For flat tweens, synthesize the keyframes from the tween's properties
const kfs =
anim.keyframes?.keyframes ??
+14
View File
@@ -24,6 +24,20 @@ export function setElementGsapPosition(element: HTMLElement, x: number, y: numbe
return true;
}
/**
* Set the element's GSAP scale. Returns false when no runtime is reachable.
*
* Used to make the element show a scale that has been committed but not yet
* re-rendered by the timeline, so measuring it afterwards reports where the
* commit actually puts it rather than where it happened to be mid-flight.
*/
export function setElementGsapScale(element: HTMLElement, x: number, y: number): boolean {
const gsap = gsapOf(element);
if (!gsap?.set) return false;
gsap.set(element, { scaleX: x, scaleY: y });
return true;
}
/** The element's GSAP numeric property, or null when unreadable. */
export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
const value = Number(gsapOf(element)?.getProperty?.(element, prop));