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