fix(studio): an element placed by a stylesheet translate stays put after a move or resize (#4734)

* fix(studio): an element placed by a stylesheet translate stays put after a drag or resize

* fix(studio): an unset computed translate reads as nothing underneath

* fix(studio): an element on the old offset channel keeps its folded translate masked too

* test(studio): pin that a drag GSAP does not own leaves its translate unmasked

* test(studio): the unmasked translate still carries the offset channel
This commit is contained in:
Miguel Ángel
2026-09-30 03:51:21 +00:00
committed by GitHub
parent f5a348e92d
commit 4825f79294
2 changed files with 71 additions and 9 deletions
@@ -449,6 +449,62 @@ describe("GSAP-element drag — dot-a flies regressions", () => {
});
});
describe("a stylesheet translate after a committed GSAP drag", () => {
function draggedTitle(css: string, oldOffsetChannel = false, withGsap = true) {
const window = new Window();
const style = window.document.createElement("style");
style.textContent = css;
window.document.head.append(style);
const element = window.document.createElement("h1");
element.id = "title";
if (oldOffsetChannel) {
element.setAttribute("data-hf-studio-path-offset", "true");
element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px");
element.style.setProperty(STUDIO_OFFSET_Y_PROP, "0px");
element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
}
window.document.body.append(element);
element.getBoundingClientRect = () => new window.DOMRect(10, 20, 100, 50);
(window as unknown as { gsap: unknown }).gsap = withGsap && {
set: (el: HTMLElement, vars: { x?: number; y?: number }) =>
el.style.setProperty("transform", `translate(${vars.x ?? 0}px, ${vars.y ?? 0}px)`),
getProperty: () => 0,
};
const result = createManualOffsetDragMember({
key: "title",
selection: { element } as never,
element,
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
});
if (!result.ok) throw new Error("member not created");
applyManualOffsetDragCommit(result.member, 40, 30);
if (withGsap) expect(element.style.getPropertyValue("translate")).toBe("none");
endManualOffsetDragMembers([result.member]);
return element;
}
it("stays masked, so it is not applied on top of the committed transform", () => {
expect(
draggedTitle("#title { translate: 0 -200px; }").style.getPropertyValue("translate"),
).toBe("none");
});
it("stays masked for an element still on the old offset channel", () => {
const element = draggedTitle("#title { translate: 0 -200px; }", true);
expect(element.hasAttribute("data-hf-studio-path-offset")).toBe(false);
expect(element.style.getPropertyValue("translate")).toBe("none");
});
it("is not masked when GSAP does not own the position", () => {
const element = draggedTitle("#title { translate: 0 -200px; }", true, false);
expect(element.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
});
it("is not left behind when nothing sits under the draft's mask", () => {
expect(draggedTitle("").style.getPropertyValue("translate")).toBe("");
});
});
describe("resumeGsapTimelines", () => {
it("unpauses exactly the timelines the drag start paused, then re-seeks the player", () => {
const window = new Window();
@@ -525,6 +525,18 @@ export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]
}
}
// Drop the draft's `translate: none` so the soft reload starts clean (#1673). When GSAP owns
// the position it folded any translate into its transform, so one still showing applies twice.
function settleDraftTranslate(element: HTMLElement, gsapOwnsPosition: boolean): void {
if (element.style.getPropertyValue("translate") === "none") {
element.style.removeProperty("translate");
}
if (!gsapOwnsPosition) return;
const view = element.ownerDocument.defaultView;
const underneath = view?.getComputedStyle(element).getPropertyValue("translate");
if (underneath && underneath !== "none") element.style.setProperty("translate", "none");
}
/** Teardown after a COMMITTED drag. */
export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
for (const member of members) {
@@ -533,23 +545,17 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
member.element.removeAttribute("data-hf-drag-initial-offset-y");
member.element.removeAttribute("data-hf-drag-gsap-base-x");
member.element.removeAttribute("data-hf-drag-gsap-base-y");
// Clear the draft's `translate: none` so the soft reload starts clean —
// otherwise button-less pointermoves after the reload compute deltas
// from a stale base and fling the element off-screen (#1673).
// Do NOT clearProps:"transform" — that nukes the committed GSAP position
// and causes a visual snap-back before the soft reload re-applies it.
if (member.element.style.getPropertyValue("translate") === "none") {
member.element.style.removeProperty("translate");
}
const gsapOwnsPosition = Boolean(getOffsetDragGsap(member.element));
// Migration: when GSAP owns the position (the committed value lives in the
// GSAP transform), the legacy `--hf-studio-offset` CSS channel is obsolete.
// Clear it on the LIVE element — otherwise the leftover `translate:
// var(--hf-studio-offset)` composes with the GSAP transform and the element
// renders offset by the stale value until a full page reload (the source is
// already stripped). clearStudioPathOffset leaves `transform` untouched.
if (getOffsetDragGsap(member.element)) {
clearStudioPathOffset(member.element);
}
if (gsapOwnsPosition) clearStudioPathOffset(member.element);
settleDraftTranslate(member.element, gsapOwnsPosition);
resumeGsapTimelines(member.element);
}
}