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));