From 477f77629b15bbbaeb245202610dafbb4b9d1629 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 6 Aug 2026 15:53:36 -0700 Subject: [PATCH] fix(studio): resize from the element's real box, not a 200px guess Resizing an element whose size is driven by a scale animation committed a scale computed against a hardcoded 200px fallback, because the only original size the draft recorded was the element's INLINE width, and a composition sizes its elements from the stylesheet. A 630px chip dropped at 1260px wide committed a scale of 6.3 instead of 2, so it landed at over three times the size it was dropped at. The next drag compounded it, because that wrong scale then counted as the element's live one. The draft now records the box it measured, once, before it writes a width of its own, and the intercept reads that. The inline attributes keep their own job of restoring an inline style, which is why they cannot answer this question. --- .../src/components/editor/manualEditsDom.ts | 8 ++++ .../editor/manualEditsDomPatches.test.ts | 9 +++- .../editor/manualEditsDomPatches.ts | 5 ++ .../components/editor/manualEditsSnapshot.ts | 6 +++ .../src/components/editor/manualEditsTypes.ts | 14 ++++++ .../src/hooks/gsapResizeIntercept.test.ts | 47 +++++++++++++++++++ .../studio/src/hooks/gsapResizeIntercept.ts | 37 +++++++++++++-- 7 files changed, 120 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 0883aa05d..e34b795a7 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -12,6 +12,8 @@ import { STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, STUDIO_ORIGINAL_WIDTH_ATTR, STUDIO_ORIGINAL_HEIGHT_ATTR, + STUDIO_ORIGINAL_BOX_WIDTH_ATTR, + STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, STUDIO_ORIGINAL_MIN_WIDTH_ATTR, STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, STUDIO_ORIGINAL_MAX_WIDTH_ATTR, @@ -361,6 +363,12 @@ function writeStudioBoxSizeVars( if (!element.hasAttribute(STUDIO_BOX_SIZE_ATTR)) { element.setAttribute(STUDIO_ORIGINAL_WIDTH_ATTR, element.style.getPropertyValue("width")); element.setAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR, element.style.getPropertyValue("height")); + // Measured here and only here: this branch runs once, before the draft + // writes a width, so it is the last moment the element still has the box + // the user started with. Offset sizes are layout, so a scale animation + // running on the element does not distort them. + element.setAttribute(STUDIO_ORIGINAL_BOX_WIDTH_ATTR, String(element.offsetWidth)); + element.setAttribute(STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, String(element.offsetHeight)); element.setAttribute( STUDIO_ORIGINAL_MIN_WIDTH_ATTR, element.style.getPropertyValue("min-width"), diff --git a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts index 5ef3a539f..21edf4103 100644 --- a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts +++ b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts @@ -17,6 +17,8 @@ import { STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, STUDIO_ORIGINAL_WIDTH_ATTR, STUDIO_ORIGINAL_HEIGHT_ATTR, + STUDIO_ORIGINAL_BOX_WIDTH_ATTR, + STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, STUDIO_ORIGINAL_MIN_WIDTH_ATTR, STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, STUDIO_ORIGINAL_MAX_WIDTH_ATTR, @@ -222,6 +224,9 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => { { type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: null }, { type: "inline-style", property: "height", value: "150px" }, { type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: null }, + // Measurements, so they are cleared without restoring a style. + { type: "attribute", property: STUDIO_ORIGINAL_BOX_WIDTH_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, value: null }, { type: "inline-style", property: "min-width", value: "0px" }, { type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: null }, { type: "inline-style", property: "min-height", value: "0px" }, @@ -257,8 +262,8 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => { it("clear: bare element emits only null ops — no style restores fire when orig attrs are absent", () => { const ops = buildClearBoxSizePatches(div()); - // 3 fixed (studio-width, studio-height, box-size marker) + 14 attr-null pushes (one per BOX_SIZE_ORIG_ATTR) - expect(ops).toHaveLength(17); + // 3 fixed (studio-width, studio-height, box-size marker) + 16 attr-null pushes (one per BOX_SIZE_ORIG_ATTR) + expect(ops).toHaveLength(19); expect(ops.every((op) => op.value === null)).toBe(true); }); diff --git a/packages/studio/src/components/editor/manualEditsDomPatches.ts b/packages/studio/src/components/editor/manualEditsDomPatches.ts index 83dea1bb4..1cfbf3e0a 100644 --- a/packages/studio/src/components/editor/manualEditsDomPatches.ts +++ b/packages/studio/src/components/editor/manualEditsDomPatches.ts @@ -13,6 +13,8 @@ import { STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, STUDIO_ORIGINAL_WIDTH_ATTR, STUDIO_ORIGINAL_HEIGHT_ATTR, + STUDIO_ORIGINAL_BOX_WIDTH_ATTR, + STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, STUDIO_ORIGINAL_MIN_WIDTH_ATTR, STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, STUDIO_ORIGINAL_MAX_WIDTH_ATTR, @@ -135,6 +137,9 @@ const BOX_SIZE_STYLE_PROPS = [ const BOX_SIZE_ORIG_ATTRS: ReadonlyArray<[string, string]> = [ [STUDIO_ORIGINAL_WIDTH_ATTR, "width"], [STUDIO_ORIGINAL_HEIGHT_ATTR, "height"], + // Records a measurement rather than a style, so it restores nothing. + [STUDIO_ORIGINAL_BOX_WIDTH_ATTR, ""], + [STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, ""], [STUDIO_ORIGINAL_MIN_WIDTH_ATTR, "min-width"], [STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, "min-height"], [STUDIO_ORIGINAL_MAX_WIDTH_ATTR, "max-width"], diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts index afc8aa30e..826fb1682 100644 --- a/packages/studio/src/components/editor/manualEditsSnapshot.ts +++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts @@ -17,6 +17,8 @@ import { STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, STUDIO_ORIGINAL_WIDTH_ATTR, STUDIO_ORIGINAL_HEIGHT_ATTR, + STUDIO_ORIGINAL_BOX_WIDTH_ATTR, + STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, STUDIO_ORIGINAL_MIN_WIDTH_ATTR, STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, STUDIO_ORIGINAL_MAX_WIDTH_ATTR, @@ -60,6 +62,8 @@ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapsho marker: element.getAttribute(STUDIO_BOX_SIZE_ATTR), originalWidth: element.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR), originalHeight: element.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR), + originalBoxWidth: element.getAttribute(STUDIO_ORIGINAL_BOX_WIDTH_ATTR), + originalBoxHeight: element.getAttribute(STUDIO_ORIGINAL_BOX_HEIGHT_ATTR), originalMinWidth: element.getAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR), originalMinHeight: element.getAttribute(STUDIO_ORIGINAL_MIN_HEIGHT_ATTR), originalMaxWidth: element.getAttribute(STUDIO_ORIGINAL_MAX_WIDTH_ATTR), @@ -128,6 +132,8 @@ export function restoreStudioBoxSize(element: HTMLElement, previous: StudioBoxSi restoreAttribute(element, STUDIO_BOX_SIZE_ATTR, previous.marker); restoreAttribute(element, STUDIO_ORIGINAL_WIDTH_ATTR, previous.originalWidth); restoreAttribute(element, STUDIO_ORIGINAL_HEIGHT_ATTR, previous.originalHeight); + restoreAttribute(element, STUDIO_ORIGINAL_BOX_WIDTH_ATTR, previous.originalBoxWidth); + restoreAttribute(element, STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, previous.originalBoxHeight); restoreAttribute(element, STUDIO_ORIGINAL_MIN_WIDTH_ATTR, previous.originalMinWidth); restoreAttribute(element, STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, previous.originalMinHeight); restoreAttribute(element, STUDIO_ORIGINAL_MAX_WIDTH_ATTR, previous.originalMaxWidth); diff --git a/packages/studio/src/components/editor/manualEditsTypes.ts b/packages/studio/src/components/editor/manualEditsTypes.ts index 074a8762d..d39c9df0e 100644 --- a/packages/studio/src/components/editor/manualEditsTypes.ts +++ b/packages/studio/src/components/editor/manualEditsTypes.ts @@ -16,6 +16,18 @@ export const STUDIO_ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate export const STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR = "data-hf-studio-original-inline-translate"; export const STUDIO_ORIGINAL_WIDTH_ATTR = "data-hf-studio-original-width"; export const STUDIO_ORIGINAL_HEIGHT_ATTR = "data-hf-studio-original-height"; +/** + * The element's laid-out box before a resize draft touched it, in CSS pixels. + * + * The two attributes above record the element's INLINE width and height so a + * reset can put them back, and are empty for the usual case of an element sized + * by the stylesheet. That made them useless as a measurement, and the resize + * intercept, which needs the original box to work out a scale, fell back to a + * hardcoded guess and produced a wildly wrong one. These record the measurement + * instead, and restore nothing. + */ +export const STUDIO_ORIGINAL_BOX_WIDTH_ATTR = "data-hf-studio-original-box-width"; +export const STUDIO_ORIGINAL_BOX_HEIGHT_ATTR = "data-hf-studio-original-box-height"; export const STUDIO_ORIGINAL_MIN_WIDTH_ATTR = "data-hf-studio-original-min-width"; export const STUDIO_ORIGINAL_MIN_HEIGHT_ATTR = "data-hf-studio-original-min-height"; export const STUDIO_ORIGINAL_MAX_WIDTH_ATTR = "data-hf-studio-original-max-width"; @@ -70,6 +82,8 @@ export interface StudioBoxSizeSnapshot { marker: string | null; originalWidth: string | null; originalHeight: string | null; + originalBoxWidth: string | null; + originalBoxHeight: string | null; originalMinWidth: string | null; originalMinHeight: string | null; originalMaxWidth: string | null; diff --git a/packages/studio/src/hooks/gsapResizeIntercept.test.ts b/packages/studio/src/hooks/gsapResizeIntercept.test.ts index cc1e24112..7af5f3676 100644 --- a/packages/studio/src/hooks/gsapResizeIntercept.test.ts +++ b/packages/studio/src/hooks/gsapResizeIntercept.test.ts @@ -284,3 +284,50 @@ it("non-uniform drag commits scaleX/scaleY longhands", async () => { expect(serialized).toContain("scaleX"); expect(serialized).toContain("scaleY"); }); + +/** + * The bug: the original box size was read only from the element's INLINE + * width, and a composition sizes its elements from the stylesheet. With no + * inline width the code fell back to a hardcoded 200, so the committed scale + * came out `real / 200` times too large. Dropping a 630px chip at 2391px wide + * left it rendering at 7532px, over three times where it was dropped, and the + * next drag compounded it. + */ +it("scales from the element's real box, not a hardcoded fallback", async () => { + const el = document.createElement("div"); + el.id = "clip"; + // Sized by a stylesheet, so it carries no inline width, and the draft + // recorded the box it measured instead. + el.setAttribute("data-hf-studio-original-box-width", "630"); + el.setAttribute("data-hf-studio-original-box-height", "252"); + document.body.append(el); + const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection; + const commitMutation = vi.fn(); + + await tryGsapResizeIntercept( + selection, + { width: 1260, height: 504 }, + [keyframedScaleFixture()], + fakeIframe(el, { scaleX: 1, scaleY: 1 }), + commitMutation, + ); + + type Mutation = { + properties?: Record; + keyframes?: Array<{ percentage: number; properties: Record }>; + }; + const committed = commitMutation.mock.calls + .map((call) => call[1] as Mutation) + .flatMap((mutation) => [ + mutation.properties, + ...(mutation.keyframes ?? []).map((frame) => frame.properties), + ]) + .filter((properties): properties is Record => properties != null) + .find((properties) => properties.scale != null || properties.scaleX != null); + + // Dropped at twice the element's own size, so the scale is about 2. The + // number that matters is that it is not the 6.3 which 1260/200 produced. + const scale = committed?.scale ?? committed?.scaleX ?? 0; + expect(scale).toBeCloseTo(2, 1); + expect(scale).toBeLessThan(3); +}); diff --git a/packages/studio/src/hooks/gsapResizeIntercept.ts b/packages/studio/src/hooks/gsapResizeIntercept.ts index 04eac1fc4..95ab8fdb5 100644 --- a/packages/studio/src/hooks/gsapResizeIntercept.ts +++ b/packages/studio/src/hooks/gsapResizeIntercept.ts @@ -8,6 +8,10 @@ import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { clearStudioBoxSize } from "../components/editor/manualEdits"; +import { + STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, + STUDIO_ORIGINAL_BOX_WIDTH_ATTR, +} from "../components/editor/manualEditsTypes"; import { setElementGsapPosition } from "../utils/elementGsap"; import { usePlayerStore } from "../player/store/playerStore"; import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders"; @@ -49,6 +53,26 @@ function synthesizeIdentityProps( return id; } +/** + * The element's box before the resize draft ran, in CSS pixels. + * + * Prefers the measurement the draft recorded. Falls back to the inline style it + * saved for restoring, which is a real value for the elements that carry one, + * and null when neither says anything. + */ +function originalBoxSize( + el: HTMLElement | null, + measuredAttr: string, + inlineProperty: "width" | "height", +): number | null { + const measured = Number.parseFloat(el?.getAttribute(measuredAttr) ?? ""); + if (Number.isFinite(measured) && measured > 0) return measured; + const inline = Number.parseFloat( + el?.getAttribute(`data-hf-studio-original-${inlineProperty}`) ?? "", + ); + return Number.isFinite(inline) && inline > 0 ? inline : null; +} + // ── Resize intercept ────────────────────────────────────────────────────── // fallow-ignore-next-line complexity @@ -162,10 +186,15 @@ export async function tryGsapResizeIntercept( const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null; // The resize draft modifies el.style.width/height, so read the ORIGINAL // dimensions saved by the draft system before it ran. - const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? ""); - const origH = Number.parseFloat(el?.getAttribute("data-hf-studio-original-height") ?? ""); - const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200; - const cssH = Number.isFinite(origH) && origH > 0 ? origH : cssW; + // + // The measured box first, then the inline one. The inline attributes exist + // to restore an inline style and are empty for anything sized by a + // stylesheet, which is how compositions are written, so reading them alone + // sent almost every element to the fallback below: a 630px chip scaled by + // 630/200, landing over three times the size it was dropped at, and worse + // on the next drag because the wrong scale then counted as its live one. + const cssW = originalBoxSize(el, STUDIO_ORIGINAL_BOX_WIDTH_ATTR, "width") ?? 200; + const cssH = originalBoxSize(el, STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, "height") ?? cssW; // `size` is the draft's CSS box; on screen it is multiplied by the element's // LIVE scale (the draft divides the cursor delta by it — see // resolveDomEditResizeGesture). The committed keyframe REPLACES that live