diff --git a/packages/studio/src/components/editor/manualOffsetDrag.test.ts b/packages/studio/src/components/editor/manualOffsetDrag.test.ts
index 8ef226ba88c..cf00350bc30 100644
--- a/packages/studio/src/components/editor/manualOffsetDrag.test.ts
+++ b/packages/studio/src/components/editor/manualOffsetDrag.test.ts
@@ -13,6 +13,7 @@ import {
type ManualOffsetDragMatrix,
} from "./manualOffsetDrag";
import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
+import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
function expectMatrixClose(actual: ManualOffsetDragMatrix, expected: ManualOffsetDragMatrix): void {
expect(actual.a).toBeCloseTo(expected.a, 6);
@@ -552,3 +553,95 @@ describe("resumeGsapTimelines", () => {
expect(() => resumeGsapTimelines(element)).not.toThrow();
});
});
+
+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, setup?: (w: Window) => void) {
+ const window = new Window();
+ setup?.(window);
+ window.document.head.innerHTML = ``;
+ const element = window.document.createElement("h1");
+ element.id = "title";
+ window.document.body.append(element);
+ Object.defineProperty(element, "offsetWidth", { value: 146 });
+ Object.defineProperty(element, "offsetHeight", { value: 82 });
+ element.getBoundingClientRect = () => {
+ const x = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
+ const y = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
+ return new window.DOMRect(100 + x, 300 + y, 146, 82);
+ };
+ const result = createManualOffsetDragMember({
+ key: "title",
+ selection: { element } as never,
+ element,
+ rect: { left: 100, top: 300, width: 146, height: 82, editScaleX: 1, editScaleY: 1 },
+ });
+ if (!result.ok) throw new Error(result.reason);
+ const offset = applyManualOffsetDragCommit(result.member, dx, dy);
+ return computeDraggedGsapPosition(element, offset, { x: 0, y: 0 });
+ }
+
+ it("keeps a stylesheet translate, so a 30px drag moves the element 30px", () => {
+ const { newX, newY } = committedPosition("translate: 0 -200px;", 40, 30);
+ expect({ x: newX, y: newY }).toEqual({ x: 40, y: -170 });
+ });
+
+ it("keeps a stylesheet transform translation the same way", () => {
+ const { newX, newY } = committedPosition("transform: translate(-60px, 10px);", 40, 30);
+ expect({ x: newX, y: newY }).toEqual({ x: -20, y: 40 });
+ });
+
+ it("turns the transform's translation by a CSS rotate, as GSAP folds it", () => {
+ const { newX, newY } = committedPosition(
+ "rotate: 90deg; transform: translate(100px, 0);",
+ 40,
+ 30,
+ );
+ expect(newX).toBeCloseTo(40);
+ 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 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 });
+ });
+
+ it("leaves a -50% centering out of x/y, since GSAP keeps it as xPercent/yPercent", () => {
+ const { newX, newY } = committedPosition("translate: -50% -50%;", 40, 30);
+ expect({ x: newX, y: newY }).toEqual({ x: 40, y: 30 });
+ });
+});
diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts
index 247cfbe0c36..0c7127f383f 100644
--- a/packages/studio/src/components/editor/manualOffsetDrag.ts
+++ b/packages/studio/src/components/editor/manualOffsetDrag.ts
@@ -25,6 +25,53 @@ function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
return gsap?.set && gsap.getProperty ? (gsap as OffsetDragGsap) : null;
}
+const cssValue = (style: CSSStyleDeclaration, prop: string) => {
+ const value = style.getPropertyValue(prop).trim();
+ return value === "none" ? "" : value;
+};
+
+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(",")})`,
+ 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 {
+ const raw = Number.parseFloat(translate) || 0;
+ const t = (translate.endsWith("%") ? (raw * size) / 100 : raw) + fromTransform;
+ return t && Math.round(size / 2) === Math.round(-t) ? t + size / 2 : t;
+}
+
+// The x/y GSAP reports once the commit loads it into a page without it, as its CSSPlugin parses:
+// the CSS `translate`, then `rotate`, `scale` and `transform` in that order, less a -50% centering kept in x/yPercent.
+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 moved = foldedTranslation(view, view.getComputedStyle(element));
+ return {
+ x: foldedAxis(moved.tx, moved.x, element.offsetWidth),
+ y: foldedAxis(moved.ty, moved.y, element.offsetHeight),
+ };
+}
+
/**
* Live drag preview through the GSAP channel — the SAME channel the commit
* lands in (a `tl.set`/keyframe on the timeline), so what the user sees while
@@ -346,8 +393,10 @@ export function createManualOffsetDragMember(input: {
__timelines?: Record void; paused?: () => boolean }>;
})
| null;
- const gsapX = win?.gsap?.getProperty?.(input.element, "x") || 0;
- const gsapY = win?.gsap?.getProperty?.(input.element, "y") || 0;
+ const gsap = win?.gsap;
+ const { x: gsapX, y: gsapY } = gsap?.getProperty
+ ? { x: gsap.getProperty(input.element, "x") || 0, y: gsap.getProperty(input.element, "y") || 0 }
+ : readGsapFoldedCssPosition(input.element);
input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));