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));