From cfdb9483302fa98d34dda2a7d20ddccb06f5e067 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 6 Aug 2026 16:34:11 -0700 Subject: [PATCH] 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. --- .../studio/src/hooks/gsapResizeIntercept.ts | 61 +++++++++++++++++-- packages/studio/src/utils/elementGsap.ts | 14 +++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/hooks/gsapResizeIntercept.ts b/packages/studio/src/hooks/gsapResizeIntercept.ts index 9fc460a2e6..e904379c21 100644 --- a/packages/studio/src/hooks/gsapResizeIntercept.ts +++ b/packages/studio/src/hooks/gsapResizeIntercept.ts @@ -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; 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 ?? diff --git a/packages/studio/src/utils/elementGsap.ts b/packages/studio/src/utils/elementGsap.ts index e89b6f9864..c4d1df5890 100644 --- a/packages/studio/src/utils/elementGsap.ts +++ b/packages/studio/src/utils/elementGsap.ts @@ -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));