Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions packages/studio/src/components/editor/manualOffsetDrag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 = `<style>#title { ${css} }</style>`;
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<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 });
});

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 });
});
});
53 changes: 51 additions & 2 deletions packages/studio/src/components/editor/manualOffsetDrag.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type { DomEditSelection } from "./domEditing";
import {
applyStudioPathOffset,
Expand Down Expand Up @@ -25,6 +25,53 @@
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
Expand Down Expand Up @@ -346,8 +393,10 @@
__timelines?: Record<string, { pause?: () => 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));

Expand Down
Loading