From 91edce0701753a9d08430e768bd22f6bbe686266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 01:05:05 -0400 Subject: [PATCH] fix(studio): a no-GSAP drag falls back to the plain transform when the fold does not parse --- .../editor/manualOffsetDrag.test.ts | 52 +++++++++++++------ .../src/components/editor/manualOffsetDrag.ts | 32 +++++++----- 2 files changed, 56 insertions(+), 28 deletions(-) diff --git a/packages/studio/src/components/editor/manualOffsetDrag.test.ts b/packages/studio/src/components/editor/manualOffsetDrag.test.ts index 18933416bf..cf00350bc3 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.test.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.test.ts @@ -557,22 +557,9 @@ describe("resumeGsapTimelines", () => { describe("drag in a composition without GSAP", () => { // The commit writes `gsap.set(x, y)`; once GSAP loads it folds the CSS translate and the // transform's translation into x/y, so the drag base must already count them. - function committedPosition( - css: string, - dx: number, - dy: number, - gsapBase?: { x: number; y: number }, - ) { + function committedPosition(css: string, dx: number, dy: number, setup?: (w: Window) => void) { const window = new Window(); - if (gsapBase) { - const base: Record = gsapBase; - Object.assign(window, { - gsap: { - set: () => undefined, - getProperty: (_el: Element, prop: string) => base[prop] ?? 0, - }, - }); - } + setup?.(window); window.document.head.innerHTML = ``; const element = window.document.createElement("h1"); element.id = "title"; @@ -615,8 +602,41 @@ describe("drag in a composition without GSAP", () => { expect(newY).toBeCloseTo(130); }); + it("scales the transform's translation by a CSS scale", () => { + const { newX, newY } = committedPosition("scale: 2; transform: translate(10px, 0);", 40, 30); + expect({ x: newX, y: newY }).toEqual({ x: 60, y: 30 }); + }); + + it("falls back to the plain transform, as GSAP does, when the fold does not parse", () => { + // Chrome's DOMMatrix rejects an axis rotate inside a transform list; happy-dom's does not. + const rejectAxisRotate = (w: Window) => { + const Parsed = w.DOMMatrix; + class ChromeLike extends Parsed { + constructor(init?: string) { + if (init?.includes("rotate(x ")) throw new SyntaxError(`Failed to parse '${init}'`); + super(init); + } + } + Object.assign(w, { DOMMatrix: ChromeLike }); + }; + const { newX, newY } = committedPosition( + "rotate: x 30deg; translate: 0 -200px; transform: translate(-60px, 10px);", + 40, + 30, + rejectAxisRotate, + ); + expect({ x: newX, y: newY }).toEqual({ x: -20, y: 40 }); + }); + it("reads the base from GSAP itself once the page has it", () => { - const { newX, newY } = committedPosition("translate: 0 -200px;", 40, 30, { x: 7, y: 5 }); + const base: Record = { x: 7, y: 5 }; + const gsap = { + set: () => undefined, + getProperty: (_el: Element, prop: string) => base[prop] ?? 0, + }; + const { newX, newY } = committedPosition("translate: 0 -200px;", 40, 30, (w) => + Object.assign(w, { gsap }), + ); expect({ x: newX, y: newY }).toEqual({ x: 47, y: 35 }); }); diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index f3ad2dc62f..0c7127f383 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -30,18 +30,28 @@ const cssValue = (style: CSSStyleDeclaration, prop: string) => { return value === "none" ? "" : value; }; -function foldedTransformTranslation(view: Window & typeof globalThis, style: CSSStyleDeclaration) { +function transformTranslation(view: Window & typeof globalThis, list: string) { + const matrix = list ? new view.DOMMatrix(list) : null; + return { x: matrix?.m41 ?? 0, y: matrix?.m42 ?? 0 }; +} + +// GSAP writes `translate rotate scale transform` as one inline transform; a value the browser rejects +// (e.g. `rotate: x 30deg`) drops the whole string, leaving GSAP only the plain transform. +function foldedTranslation(view: Window & typeof globalThis, style: CSSStyleDeclaration) { + const [tx = "", ty = ""] = cssValue(style, "translate").split(/\s+/); const rotate = cssValue(style, "rotate"); const scale = cssValue(style, "scale"); + const transform = cssValue(style, "transform"); const folded = [ rotate && `rotate(${rotate})`, scale && `scale(${scale.split(/\s+/).join(",")})`, - cssValue(style, "transform"), - ] - .join(" ") - .trim(); - const matrix = folded ? new view.DOMMatrix(folded) : null; - return { x: matrix?.m41 ?? 0, y: matrix?.m42 ?? 0 }; + transform, + ]; + try { + return { tx, ty, ...transformTranslation(view, folded.join(" ").trim()) }; + } catch { + return { tx: "", ty: "", ...transformTranslation(view, transform) }; + } } function foldedAxis(translate: string, fromTransform: number, size: number): number { @@ -55,12 +65,10 @@ function foldedAxis(translate: string, fromTransform: number, size: number): num function readGsapFoldedCssPosition(element: HTMLElement): { x: number; y: number } { const view = element.ownerDocument.defaultView as (Window & typeof globalThis) | null; if (!view) return { x: 0, y: 0 }; - const style = view.getComputedStyle(element); - const [tx = "", ty = ""] = cssValue(style, "translate").split(/\s+/); - const moved = foldedTransformTranslation(view, style); + const moved = foldedTranslation(view, view.getComputedStyle(element)); return { - x: foldedAxis(tx, moved.x, element.offsetWidth), - y: foldedAxis(ty, moved.y, element.offsetHeight), + x: foldedAxis(moved.tx, moved.x, element.offsetWidth), + y: foldedAxis(moved.ty, moved.y, element.offsetHeight), }; }