fix(studio): bake the group transform into members on ungroup (#1764)

Ungroup only baked the wrapper's layout (left/top), not its GSAP transform — so a
moved group's members snapped back to their creation-time positions. Distribute the
group's static transform onto each member before stripping it: translation is an
exact per-axis add; rotation/scale are composed about the group center so off-center
members don't drift. Animated group transforms are left to be stripped, not baked.
This commit is contained in:
Miguel Ángel
2026-06-27 11:33:45 -04:00
committed by GitHub
parent cf8f8aa568
commit bc5a51a6ee
2 changed files with 176 additions and 13 deletions
@@ -404,6 +404,15 @@ export interface WrapElementsResult {
export interface UnwrapElementsResult {
html: string;
unwrapped: boolean;
/** The unwrapped wrapper's id, so callers can strip GSAP that targeted it
* (the wrapper is gone; a leftover `gsap.set("#id")` would throw at runtime). */
unwrappedGroupId?: string;
/** Members (id'd children) with their absolute layout centres (post un-rebase),
* so the caller can BAKE the group's GSAP transform into each member before
* stripping it — otherwise the group's moves are lost on ungroup. */
members?: Array<{ id: string; cx: number; cy: number }>;
/** The wrapper's layout centre — the pivot for baking the group's rotation/scale. */
groupCenter?: { cx: number; cy: number };
}
export interface ElementRebase {
@@ -554,22 +563,36 @@ export function unwrapElementsFromHtml(
// Undo the rebase: child absolute position = child (rebased) + wrapper origin.
const wLeft = getInlineStylePx(group, "left");
const wTop = getInlineStylePx(group, "top");
const groupCenter = {
cx: wLeft + getInlineStylePx(group, "width") / 2,
cy: wTop + getInlineStylePx(group, "height") / 2,
};
// Move children back to the wrapper's slot, preserving order.
const members: Array<{ id: string; cx: number; cy: number }> = [];
for (const child of Array.from(group.children)) {
if (isHTMLElement(child)) {
setInlineLeftTop(
child,
getInlineStylePx(child, "left") + wLeft,
getInlineStylePx(child, "top") + wTop,
);
const newLeft = getInlineStylePx(child, "left") + wLeft;
const newTop = getInlineStylePx(child, "top") + wTop;
setInlineLeftTop(child, newLeft, newTop);
if (child.id) {
members.push({
id: child.id,
cx: newLeft + getInlineStylePx(child, "width") / 2,
cy: newTop + getInlineStylePx(child, "height") / 2,
});
}
}
parent.insertBefore(child, group);
}
const groupId = group.id || undefined;
group.remove();
return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
unwrapped: true,
unwrappedGroupId: groupId,
members,
groupCenter,
};
}
+148 -8
View File
@@ -314,6 +314,137 @@ function extractGsapScriptBlock(html: string): {
return null;
}
/**
* Remove every GSAP animation that targets `selector` from an HTML string's
* inline script. Used after unwrapping a group so its leftover `gsap.set("#id")`
* (the wrapper is gone) doesn't throw "target not found" on every preview run.
*/
function stripGsapAnimationsForSelector(html: string, selector: string): string {
const block = extractGsapScriptBlock(html);
if (!block) return html;
const parsed = parseGsapScriptAcorn(block.scriptText);
const matching = parsed.animations.filter((a) => a.targetSelector === selector);
if (matching.length === 0) return html;
let script = block.scriptText;
// Reverse so earlier removals don't shift the spans of later ones.
for (const anim of [...matching].reverse()) {
script = removeAnimationFromScript(script, anim.id);
}
return block.replaceScript(script);
}
/**
* Bake a group's STATIC GSAP transform into each member BEFORE the group is
* stripped on ungroup. Moving a group is stored as `gsap.set("#group-1",{x,y,…})`;
* without distributing it to the members they snap back to their creation-time
* positions. Translation (x/y/z) is an exact per-axis add; rotation/scale are
* composed about the group's centre (the pivot) so off-centre members don't drift.
* Animated group transforms (keyframes/tweens) are NOT baked — left to be stripped.
*/
function bakeGroupTransformIntoMembers(
html: string,
groupId: string,
members: Array<{ id: string; cx: number; cy: number }>,
groupCenter: { cx: number; cy: number },
): string {
const block = extractGsapScriptBlock(html);
if (!block) return html;
const parsed = parseGsapScriptAcorn(block.scriptText);
const groupSel = `#${groupId}`;
const groupSets = parsed.animations.filter(
(a) => a.targetSelector === groupSel && a.method === "set",
);
if (groupSets.length === 0) return html;
// Merge the group's sets (later per-prop wins) → its effective static transform.
const gt: Record<string, number> = {};
for (const s of groupSets) {
for (const [k, v] of Object.entries(s.properties)) if (typeof v === "number") gt[k] = v;
}
const gx = gt.x ?? 0;
const gy = gt.y ?? 0;
const gz = gt.z ?? 0;
const grot = gt.rotation ?? 0;
const gscale = gt.scale ?? 1;
// Identity across ALL axes (incl. the extras baked below) — else a group whose
// only transform is e.g. scaleX would skip the bake and silently drop it.
const isScaleAxis = (k: string) => k === "scale" || k === "scaleX" || k === "scaleY";
const groupIsIdentity = Object.entries(gt).every(([k, v]) =>
isScaleAxis(k) ? v === 1 : v === 0,
);
if (groupIsIdentity) return html;
const rad = (grot * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const round3 = (n: number) => Math.round(n * 1000) / 1000;
let script = block.scriptText;
for (const m of members) {
const memberSel = `#${m.id}`;
const sets = parsed.animations.filter(
(a) => a.targetSelector === memberSel && a.method === "set",
);
// Effective member transform (merge its sets — last per-prop wins).
const mProps: Record<string, number | string> = {};
for (const s of sets) Object.assign(mProps, s.properties);
const mx = typeof mProps.x === "number" ? mProps.x : 0;
const my = typeof mProps.y === "number" ? mProps.y : 0;
// Compose the group transform onto the member's centre, then back to an offset.
const dx = m.cx + mx - groupCenter.cx;
const dy = m.cy + my - groupCenter.cy;
const visX = groupCenter.cx + gscale * (cos * dx - sin * dy) + gx;
const visY = groupCenter.cy + gscale * (sin * dx + cos * dy) + gy;
const newProps: Record<string, number | string> = {
...mProps,
x: round3(visX - m.cx),
y: round3(visY - m.cy),
};
if (gz !== 0) newProps.z = (typeof mProps.z === "number" ? mProps.z : 0) + gz;
if (grot !== 0) {
newProps.rotation = round3(
(typeof mProps.rotation === "number" ? mProps.rotation : 0) + grot,
);
}
if (gscale !== 1) {
newProps.scale = round3((typeof mProps.scale === "number" ? mProps.scale : 1) * gscale);
}
// Bake any REMAINING group transform axis so nothing is silently dropped on
// ungroup. The pivot-composed axes (x/y/z/rotation/scale) are handled above;
// these extras (scaleX/Y, rotationX/Y/Z, skewX/Y, transformPerspective) compose
// about the member's own origin — exact for a member at the group centre, a
// close approximation otherwise (groups rarely carry these).
const pivoted = new Set(["x", "y", "z", "rotation", "scale"]);
for (const [k, v] of Object.entries(gt)) {
if (pivoted.has(k) || typeof v !== "number") continue;
if (k === "scaleX" || k === "scaleY") {
if (v !== 1) newProps[k] = round3((typeof mProps[k] === "number" ? mProps[k] : 1) * v);
} else if (k === "transformPerspective") {
// Adopt the group's lens only if the member has none of its own — never
// silently overwrite a member's existing perspective.
if (typeof mProps[k] !== "number") newProps[k] = v;
} else if (v !== 0) {
newProps[k] = round3((typeof mProps[k] === "number" ? mProps[k] : 0) + v);
}
}
// Strip ALL the member's existing sets and write ONE fresh gsap.set at position
// 0. The baked transform is the member's static base — writing it to an arbitrary
// "last" set could land it at a non-zero timeline position, or leave stale earlier
// sets that override it. Reverse-remove so spans don't shift, then add fresh.
for (const s of [...sets].reverse()) {
script = removeAnimationFromScript(script, s.id);
}
script = addAnimationToScript(script, {
targetSelector: memberSel,
method: "set",
position: 0,
properties: newProps,
global: true,
}).script;
}
return block.replaceScript(script);
}
function stripStudioEditsFromTarget(document: Document, selector: string): number {
if (!selector) return 0;
let stripped = 0;
@@ -1649,14 +1780,23 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
if (!result.unwrapped) {
return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
}
return writeIfChanged(
c,
ctx.project.dir,
ctx.filePath,
ctx.absPath,
originalContent,
result.html,
);
// BAKE the group's static transform into the members FIRST, so the group's
// accumulated moves are preserved (otherwise members snap back to their
// creation-time positions), THEN strip the group's GSAP — a leftover
// `gsap.set("#group-1")` throws "target not found" every preview run.
let cleaned = result.html;
if (result.unwrappedGroupId && result.members && result.groupCenter) {
cleaned = bakeGroupTransformIntoMembers(
cleaned,
result.unwrappedGroupId,
result.members,
result.groupCenter,
);
}
if (result.unwrappedGroupId) {
cleaned = stripGsapAnimationsForSelector(cleaned, `#${result.unwrappedGroupId}`);
}
return writeIfChanged(c, ctx.project.dir, ctx.filePath, ctx.absPath, originalContent, cleaned);
});
api.post("/projects/:id/file-mutations/probe-element/*", async (c) => {