fix(studio): a no-GSAP drag falls back to the plain transform when the fold does not parse

This commit is contained in:
Miguel Ángel
2026-09-30 01:05:05 -04:00
parent 73e94d7415
commit 91edce0701
2 changed files with 56 additions and 28 deletions
@@ -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<string, number> = gsapBase;
Object.assign(window, {
gsap: {
set: () => undefined,
getProperty: (_el: Element, prop: string) => base[prop] ?? 0,
},
});
}
setup?.(window);
window.document.head.innerHTML = `<style>#title { ${css} }</style>`;
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<string, number> = { 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 });
});
@@ -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),
};
}