Skip to content

Commit ee5ae96

Browse files
committed
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.
1 parent b18fd62 commit ee5ae96

2 files changed

Lines changed: 127 additions & 8 deletions

File tree

‎packages/studio/src/hooks/gsapResizeIntercept.test.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,102 @@ it("does not mix the scale shorthand into a tween that speaks longhands", async
381381
const resized = frames.find((frame) => frame.properties.scaleX != null);
382382
expect(resized?.properties.scaleX).toBeCloseTo(0.61, 1);
383383
});
384+
385+
/**
386+
* The bug: a scale resize measured its drop-point correction while the
387+
* gesture's own translation was still applied, but the position commit adds
388+
* that correction onto the element's PRE-gesture position (it reads the
389+
* gesture's base attributes). The two disagreed by the whole drag distance, so
390+
* every scale resize of a statically positioned element persisted a position a
391+
* drag-length away from where it was dropped — the element held still for one
392+
* frame and then slid off.
393+
*
394+
* The fixture models the geometry the browser reported: a 630x252 element
395+
* dragged from x=432 to x=587 with its box drafted down to 320x128, dropped at
396+
* a committed scale of 0.837. Scaling about the centre puts it back on the drop
397+
* point at its pre-gesture position, so the correct persisted correction is
398+
* NONE.
399+
*/
400+
it("does not move a statically positioned element when a scale resize lands", async () => {
401+
document.body.innerHTML = "";
402+
const el = document.createElement("div");
403+
el.id = "clip";
404+
el.setAttribute("data-hf-studio-original-box-width", "630");
405+
el.setAttribute("data-hf-studio-original-box-height", "252");
406+
// The gesture's base pose — where the commit puts the element back, since a
407+
// scale resize never persists the drag translation.
408+
el.setAttribute("data-hf-drag-gsap-base-x", "432");
409+
el.setAttribute("data-hf-drag-gsap-base-y", "173");
410+
// The draft the gesture left applied: a smaller box at the dragged position.
411+
el.setAttribute("data-hf-studio-box-size", "true");
412+
el.setAttribute("data-hf-studio-original-width", "");
413+
el.setAttribute("data-hf-studio-original-height", "");
414+
el.style.width = "320px";
415+
el.style.height = "128px";
416+
document.body.append(el);
417+
418+
const pos = { x: 587, y: 235 };
419+
const scale = { x: 1.648, y: 1.648 };
420+
const [LEFT, TOP] = [120, 520];
421+
el.getBoundingClientRect = () => {
422+
const cssW = Number.parseFloat(el.style.width) || 630;
423+
const cssH = Number.parseFloat(el.style.height) || 252;
424+
const [w, h] = [cssW * scale.x, cssH * scale.y];
425+
// GSAP scales about the element centre, so the box grows around it.
426+
return {
427+
x: LEFT + pos.x + cssW / 2 - w / 2,
428+
y: TOP + pos.y + cssH / 2 - h / 2,
429+
width: w,
430+
height: h,
431+
} as DOMRect;
432+
};
433+
const gsapStub = {
434+
set: (_target: Element, vars: Record<string, number>) => {
435+
if (vars.x != null) pos.x = vars.x;
436+
if (vars.y != null) pos.y = vars.y;
437+
if (vars.scaleX != null) scale.x = vars.scaleX;
438+
if (vars.scaleY != null) scale.y = vars.scaleY;
439+
},
440+
getProperty: (_target: Element, prop: string) =>
441+
({ scaleX: scale.x, scaleY: scale.y, x: pos.x, y: pos.y })[prop] ?? 0,
442+
};
443+
Object.assign(window, { gsap: gsapStub });
444+
const iframe = {
445+
contentWindow: { gsap: gsapStub, __timelines: {} },
446+
contentDocument: document,
447+
} as unknown as HTMLIFrameElement;
448+
const positionHold = {
449+
id: "#clip-set-0-position",
450+
targetSelector: "#clip",
451+
propertyGroup: "position",
452+
method: "set",
453+
properties: { x: 432, y: 173 },
454+
position: 0,
455+
resolvedStart: 0,
456+
duration: 0,
457+
global: true,
458+
} as unknown as GsapAnimation;
459+
const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
460+
usePlayerStore.setState({ currentTime: 0.5 });
461+
const commitMutation = vi.fn();
462+
463+
await tryGsapResizeIntercept(
464+
selection,
465+
{ width: 320, height: 128 },
466+
[keyframedScaleFixture(), positionHold],
467+
iframe,
468+
commitMutation,
469+
async () => [keyframedScaleFixture(), positionHold],
470+
);
471+
472+
const positionWrites = commitMutation.mock.calls
473+
.map((call) => call[1] as { properties?: Record<string, number> })
474+
.filter((mutation) => mutation.properties?.x != null || mutation.properties?.y != null);
475+
// Either it left the position alone, or it rewrote the same value.
476+
for (const write of positionWrites) {
477+
expect(write.properties?.x).toBe(432);
478+
expect(write.properties?.y).toBe(173);
479+
}
480+
// And the live element ends on the drop point, not a drag away from it.
481+
expect(el.getBoundingClientRect().x).toBeCloseTo(603.3, 0);
482+
});

‎packages/studio/src/hooks/gsapResizeIntercept.ts‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "../components/editor/manualEditsTypes";
1515
import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
1616
import { usePlayerStore } from "../player/store/playerStore";
17+
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
1718
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
1819
import {
1920
commitStaticGsapPosition,
@@ -25,6 +26,7 @@ import {
2526
materializeIfDynamic,
2627
} from "./gsapDragCommit";
2728
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
29+
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
2830
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
2931
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
3032
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
@@ -303,26 +305,44 @@ export async function tryGsapResizeIntercept(
303305
if (committedScale) {
304306
setElementGsapScale(scaleDraftEl, committedScale.x, committedScale.y);
305307
}
308+
// Measure from the pre-gesture position, not the draft one.
309+
//
310+
// The resize draft translates the element to keep the dragged corner under
311+
// the cursor, but the scale route never persists that translation — the
312+
// element renders back at its pre-gesture position as soon as the commit
313+
// lands. Measuring while the draft translation was still applied made the
314+
// residual carry the whole drag distance, and the position commit then
315+
// composed that residual onto the pre-gesture base (it reads the gesture's
316+
// own base attributes, not the live value), so the element landed a full
317+
// drag away from the drop point on every scale resize.
318+
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
319+
const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
320+
selection.element,
321+
{ x: 0, y: 0 },
322+
gsapPos,
323+
);
324+
const base = { x: baseGsapX, y: baseGsapY };
325+
setElementGsapPosition(scaleDraftEl, base.x, base.y);
306326
const post = scaleDraftEl.getBoundingClientRect();
307327
const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
308328
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return;
309329
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
310-
logResize("scale-finalize", { skipped: "already-on-drop-point", residual });
330+
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
311331
return;
312332
}
313-
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
314333
// The ONE corrected position — rounded once so the live runtime and the
315334
// persisted file agree exactly (commitStaticGsapPosition composes the same
316335
// rounded value from this delta).
317336
const corrected = {
318-
x: Math.round(gsapPos.x + residual.x),
319-
y: Math.round(gsapPos.y + residual.y),
337+
x: Math.round(base.x + residual.x),
338+
y: Math.round(base.y + residual.y),
320339
};
321340
logResize("scale-finalize", {
322341
dropPoint: scaleDraftDropPoint,
323342
post: { x: post.x, y: post.y },
324343
residual,
325344
gsapPos,
345+
base,
326346
corrected,
327347
});
328348
// Correct the LIVE runtime NOW, synchronously: the soft reload above just
@@ -339,8 +359,8 @@ export async function tryGsapResizeIntercept(
339359
? await fetchFallbackAnimations()
340360
: (resolved?.animations ?? animations);
341361
// Delta chosen so the drag-path math composes back to exactly `corrected`
342-
// (no drag scratch attrs exist during a resize, so base = gsapPos).
343-
const delta = { x: corrected.x - gsapPos.x, y: corrected.y - gsapPos.y };
362+
// — it adds this onto the same base the measurement above used.
363+
const delta = { x: corrected.x - base.x, y: corrected.y - base.y };
344364
// An element whose position is animated needs the correction written into
345365
// that animation, at the playhead, or the tween renders its own value a
346366
// frame later and the element leaves the drop point anyway. This used to
@@ -355,14 +375,14 @@ export async function tryGsapResizeIntercept(
355375
);
356376
if (positionTween) {
357377
logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
358-
await commitGsapPositionFromDrag(selection, positionTween, delta, gsapPos, iframe, selector, {
378+
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, selector, {
359379
commitMutation,
360380
fetchAnimations: fetchFallbackAnimations,
361381
});
362382
return;
363383
}
364384
const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
365-
await commitStaticGsapPosition(selection, delta, gsapPos, selector, existingSet, {
385+
await commitStaticGsapPosition(selection, delta, base, selector, existingSet, {
366386
commitMutation,
367387
fetchAnimations: fetchFallbackAnimations,
368388
});

0 commit comments

Comments
 (0)