Skip to content
Open
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
94 changes: 55 additions & 39 deletions packages/studio/src/components/editor/manualEditsDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,13 +250,61 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
}
}

// GSAP owns the element's `transform` (it bakes x/y into a matrix and writes
// `translate: none` every tick). Folding the drag offset into a CSS `translate`
// — as the non-GSAP path does — composes ON TOP of GSAP's transform, and the
// subsequent strip/reapply math compounds into a runaway matrix that flings the
// element off-canvas. So for GSAP-animated elements we keep `translate: none`
// and push the offset straight into GSAP's x/y via gsap.set; the var() offset is
// still persisted (buildPathOffsetPatches), and GSAP re-reads it at init on
// reload. Returns true when handled as GSAP (caller must skip the CSS path).
function applyStudioPathOffsetViaGsap(
element: HTMLElement,
offset: { x: number; y: number },
): boolean {
if (!gsapAnimatesProperty(element, "x", "y")) return false;
element.style.setProperty("translate", "none");
const win = element.ownerDocument.defaultView as
| (Window & {
gsap?: {
set: (el: Element, vars: Record<string, unknown>) => void;
getProperty: (el: Element, prop: string) => number;
};
})
| null;
if (win?.gsap) {
const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
const gsapBaseX = Number.isFinite(baseX)
? baseX
: (win.gsap.getProperty(element, "x") as number);
const gsapBaseY = Number.isFinite(baseY)
? baseY
: (win.gsap.getProperty(element, "y") as number);
if (!Number.isFinite(baseX))
element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
if (!Number.isFinite(baseY))
element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
}
return true;
}

export function applyStudioPathOffset(
element: HTMLElement,
offset: { x: number; y: number },
options: { updateBase?: boolean } = {},
): void {
promoteInlineForTransform(element);
writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true });
// GSAP elements: route through gsap.set, NOT a CSS translate (would corrupt the
// matrix). Symmetrical with applyStudioPathOffsetDraft — the commit path used to
// skip this branch, which is what flung dragged GSAP elements off-canvas.
if (applyStudioPathOffsetViaGsap(element, offset)) return;
element.style.setProperty(
"translate",
composeTranslateValue(
Expand All @@ -274,45 +322,13 @@ export function applyStudioPathOffsetDraft(
): void {
promoteInlineForTransform(element);
writeStudioPathOffsetVars(element, offset, { updateBase: false });

const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
if (isGsapAnimated) {
element.style.setProperty("translate", "none");
const win = element.ownerDocument.defaultView as
| (Window & {
gsap?: {
set: (el: Element, vars: Record<string, unknown>) => void;
getProperty: (el: Element, prop: string) => number;
};
})
| null;
if (win?.gsap) {
const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
const gsapBaseX = Number.isFinite(baseX)
? baseX
: (win.gsap.getProperty(element, "x") as number);
const gsapBaseY = Number.isFinite(baseY)
? baseY
: (win.gsap.getProperty(element, "y") as number);
if (!Number.isFinite(baseX))
element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
if (!Number.isFinite(baseY))
element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
}
} else {
// Non-GSAP elements: use CSS translate as before.
element.style.setProperty(
"translate",
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
);
stripGsapTranslateFromTransform(element);
}
if (applyStudioPathOffsetViaGsap(element, offset)) return;
// Non-GSAP elements: use CSS translate as before.
element.style.setProperty(
"translate",
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
);
stripGsapTranslateFromTransform(element);
}

/* ── Box size apply ───────────────────────────────────────────────── */
Expand Down
82 changes: 82 additions & 0 deletions packages/studio/src/components/editor/manualEditsDomGsap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { applyStudioPathOffset, applyStudioPathOffsetDraft } from "./manualEditsDom";

/**
* Regression: dragging a GSAP-animated element (e.g. a flat `to(#el, {x})` tween)
* must NOT fold the offset into a CSS `translate`. GSAP owns `style.transform`, so
* a CSS translate composes on top of it and the strip/reapply math compounds into
* a runaway matrix that flings the element off-canvas. Both the live draft and the
* commit must instead push the offset into GSAP's x/y via gsap.set and keep
* `translate: none`. Before the fix, the commit (applyStudioPathOffset) skipped the
* GSAP branch the draft already had — that asymmetry caused the off-canvas jump.
*/

function makeGsapWindow(
el: HTMLElement,
gsapSet: (e: Element, v: Record<string, unknown>) => void,
) {
const win = el.ownerDocument.defaultView as unknown as {
__timelines?: Record<string, unknown>;
gsap?: unknown;
};
win.__timelines = {
playground: {
getChildren: () => [{ targets: () => [el], vars: { x: -260 } }],
},
};
win.gsap = {
set: gsapSet,
getProperty: () => 0,
};
}

afterEach(() => {
const win = window as unknown as { __timelines?: unknown; gsap?: unknown };
delete win.__timelines;
delete win.gsap;
});

describe("applyStudioPathOffset — GSAP-owned transform", () => {
it("non-GSAP element folds the offset into a CSS translate var()", () => {
const el = document.createElement("div");
document.body.appendChild(el);

applyStudioPathOffset(el, { x: -120, y: 40 });

expect(el.style.translate).toContain("var(--hf-studio-offset-x");
expect(el.style.getPropertyValue("--hf-studio-offset-x")).toBe("-120px");
expect(el.style.getPropertyValue("--hf-studio-offset-y")).toBe("40px");
});

it("GSAP element keeps translate:none and routes the offset through gsap.set", () => {
const el = document.createElement("div");
el.id = "puck-a";
document.body.appendChild(el);
const gsapSet = vi.fn();
makeGsapWindow(el, gsapSet);

applyStudioPathOffset(el, { x: -409, y: 398 });

// No CSS translate to collide with GSAP's transform.
expect(el.style.translate).toBe("none");
expect(el.style.translate).not.toContain("var(");
// Offset pushed into GSAP's x/y (gsapBase 0 + delta = the offset itself here).
expect(gsapSet).toHaveBeenCalledWith(el, { x: -409, y: 398 });
});

it("draft and commit treat a GSAP element identically (translate:none)", () => {
const el = document.createElement("div");
el.id = "puck-a";
document.body.appendChild(el);
makeGsapWindow(el, vi.fn());

applyStudioPathOffsetDraft(el, { x: -50, y: 10 });
const draftTranslate = el.style.translate;
applyStudioPathOffset(el, { x: -50, y: 10 });
const commitTranslate = el.style.translate;

expect(draftTranslate).toBe("none");
expect(commitTranslate).toBe("none");
});
});
Loading
Loading