diff --git a/packages/studio/src/hooks/gsapResizeIntercept.test.ts b/packages/studio/src/hooks/gsapResizeIntercept.test.ts index f30eb42b0..715299709 100644 --- a/packages/studio/src/hooks/gsapResizeIntercept.test.ts +++ b/packages/studio/src/hooks/gsapResizeIntercept.test.ts @@ -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) => { + 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 }) + .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); +}); diff --git a/packages/studio/src/hooks/gsapResizeIntercept.ts b/packages/studio/src/hooks/gsapResizeIntercept.ts index 7e4b19d35..583bc064b 100644 --- a/packages/studio/src/hooks/gsapResizeIntercept.ts +++ b/packages/studio/src/hooks/gsapResizeIntercept.ts @@ -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, });