Skip to content

Commit eaba83c

Browse files
committed
fix(studio): bake the group transform into members on ungroup
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.
1 parent 45c4144 commit eaba83c

2 files changed

Lines changed: 122 additions & 9 deletions

File tree

packages/core/src/studio-api/helpers/sourceMutation.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,12 @@ export interface UnwrapElementsResult {
407407
/** The unwrapped wrapper's id, so callers can strip GSAP that targeted it
408408
* (the wrapper is gone; a leftover `gsap.set("#id")` would throw at runtime). */
409409
unwrappedGroupId?: string;
410+
/** Members (id'd children) with their absolute layout centres (post un-rebase),
411+
* so the caller can BAKE the group's GSAP transform into each member before
412+
* stripping it — otherwise the group's moves are lost on ungroup. */
413+
members?: Array<{ id: string; cx: number; cy: number }>;
414+
/** The wrapper's layout centre — the pivot for baking the group's rotation/scale. */
415+
groupCenter?: { cx: number; cy: number };
410416
}
411417

412418
export interface ElementRebase {
@@ -536,15 +542,25 @@ export function unwrapElementsFromHtml(
536542
// Undo the rebase: child absolute position = child (rebased) + wrapper origin.
537543
const wLeft = getInlineStylePx(group, "left");
538544
const wTop = getInlineStylePx(group, "top");
545+
const groupCenter = {
546+
cx: wLeft + getInlineStylePx(group, "width") / 2,
547+
cy: wTop + getInlineStylePx(group, "height") / 2,
548+
};
539549

540550
// Move children back to the wrapper's slot, preserving order.
551+
const members: Array<{ id: string; cx: number; cy: number }> = [];
541552
for (const child of Array.from(group.children)) {
542553
if (isHTMLElement(child)) {
543-
setInlineLeftTop(
544-
child,
545-
getInlineStylePx(child, "left") + wLeft,
546-
getInlineStylePx(child, "top") + wTop,
547-
);
554+
const newLeft = getInlineStylePx(child, "left") + wLeft;
555+
const newTop = getInlineStylePx(child, "top") + wTop;
556+
setInlineLeftTop(child, newLeft, newTop);
557+
if (child.id) {
558+
members.push({
559+
id: child.id,
560+
cx: newLeft + getInlineStylePx(child, "width") / 2,
561+
cy: newTop + getInlineStylePx(child, "height") / 2,
562+
});
563+
}
548564
}
549565
parent.insertBefore(child, group);
550566
}
@@ -555,5 +571,7 @@ export function unwrapElementsFromHtml(
555571
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
556572
unwrapped: true,
557573
unwrappedGroupId: groupId,
574+
members,
575+
groupCenter,
558576
};
559577
}

packages/core/src/studio-api/routes/files.ts

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,90 @@ function stripGsapAnimationsForSelector(html: string, selector: string): string
333333
return block.replaceScript(script);
334334
}
335335

336+
/**
337+
* Bake a group's STATIC GSAP transform into each member BEFORE the group is
338+
* stripped on ungroup. Moving a group is stored as `gsap.set("#group-1",{x,y,…})`;
339+
* without distributing it to the members they snap back to their creation-time
340+
* positions. Translation (x/y/z) is an exact per-axis add; rotation/scale are
341+
* composed about the group's centre (the pivot) so off-centre members don't drift.
342+
* Animated group transforms (keyframes/tweens) are NOT baked — left to be stripped.
343+
*/
344+
function bakeGroupTransformIntoMembers(
345+
html: string,
346+
groupId: string,
347+
members: Array<{ id: string; cx: number; cy: number }>,
348+
groupCenter: { cx: number; cy: number },
349+
): string {
350+
const block = extractGsapScriptBlock(html);
351+
if (!block) return html;
352+
const parsed = parseGsapScriptAcorn(block.scriptText);
353+
const groupSel = `#${groupId}`;
354+
const groupSets = parsed.animations.filter(
355+
(a) => a.targetSelector === groupSel && a.method === "set",
356+
);
357+
if (groupSets.length === 0) return html;
358+
// Merge the group's sets (later per-prop wins) → its effective static transform.
359+
const gt: Record<string, number> = {};
360+
for (const s of groupSets) {
361+
for (const [k, v] of Object.entries(s.properties)) if (typeof v === "number") gt[k] = v;
362+
}
363+
const gx = gt.x ?? 0;
364+
const gy = gt.y ?? 0;
365+
const gz = gt.z ?? 0;
366+
const grot = gt.rotation ?? 0;
367+
const gscale = gt.scale ?? 1;
368+
if (gx === 0 && gy === 0 && gz === 0 && grot === 0 && gscale === 1) return html;
369+
370+
const rad = (grot * Math.PI) / 180;
371+
const cos = Math.cos(rad);
372+
const sin = Math.sin(rad);
373+
const round3 = (n: number) => Math.round(n * 1000) / 1000;
374+
375+
let script = block.scriptText;
376+
for (const m of members) {
377+
const memberSel = `#${m.id}`;
378+
const sets = parsed.animations.filter(
379+
(a) => a.targetSelector === memberSel && a.method === "set",
380+
);
381+
// Effective member transform (merge its sets — last per-prop wins).
382+
const mProps: Record<string, number | string> = {};
383+
for (const s of sets) Object.assign(mProps, s.properties);
384+
const mx = typeof mProps.x === "number" ? mProps.x : 0;
385+
const my = typeof mProps.y === "number" ? mProps.y : 0;
386+
// Compose the group transform onto the member's centre, then back to an offset.
387+
const dx = m.cx + mx - groupCenter.cx;
388+
const dy = m.cy + my - groupCenter.cy;
389+
const visX = groupCenter.cx + gscale * (cos * dx - sin * dy) + gx;
390+
const visY = groupCenter.cy + gscale * (sin * dx + cos * dy) + gy;
391+
const newProps: Record<string, number | string> = {
392+
...mProps,
393+
x: round3(visX - m.cx),
394+
y: round3(visY - m.cy),
395+
};
396+
if (gz !== 0) newProps.z = (typeof mProps.z === "number" ? mProps.z : 0) + gz;
397+
if (grot !== 0) {
398+
newProps.rotation = round3((typeof mProps.rotation === "number" ? mProps.rotation : 0) + grot);
399+
}
400+
if (gscale !== 1) {
401+
newProps.scale = round3((typeof mProps.scale === "number" ? mProps.scale : 1) * gscale);
402+
}
403+
404+
const last = sets[sets.length - 1];
405+
if (last) {
406+
script = updateAnimationInScript(script, last.id, { properties: newProps });
407+
} else {
408+
script = addAnimationToScript(script, {
409+
targetSelector: memberSel,
410+
method: "set",
411+
position: 0,
412+
properties: newProps,
413+
global: true,
414+
}).script;
415+
}
416+
}
417+
return block.replaceScript(script);
418+
}
419+
336420
function stripStudioEditsFromTarget(document: Document, selector: string): number {
337421
if (!selector) return 0;
338422
let stripped = 0;
@@ -1668,11 +1752,22 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
16681752
if (!result.unwrapped) {
16691753
return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
16701754
}
1671-
// The wrapper is gone — strip any GSAP that targeted it, or a leftover
1755+
// BAKE the group's static transform into the members FIRST, so the group's
1756+
// accumulated moves are preserved (otherwise members snap back to their
1757+
// creation-time positions), THEN strip the group's GSAP — a leftover
16721758
// `gsap.set("#group-1")` throws "target not found" every preview run.
1673-
const cleaned = result.unwrappedGroupId
1674-
? stripGsapAnimationsForSelector(result.html, `#${result.unwrappedGroupId}`)
1675-
: result.html;
1759+
let cleaned = result.html;
1760+
if (result.unwrappedGroupId && result.members && result.groupCenter) {
1761+
cleaned = bakeGroupTransformIntoMembers(
1762+
cleaned,
1763+
result.unwrappedGroupId,
1764+
result.members,
1765+
result.groupCenter,
1766+
);
1767+
}
1768+
if (result.unwrappedGroupId) {
1769+
cleaned = stripGsapAnimationsForSelector(cleaned, `#${result.unwrappedGroupId}`);
1770+
}
16761771
return writeIfChanged(c, ctx.project.dir, ctx.filePath, ctx.absPath, originalContent, cleaned);
16771772
});
16781773

0 commit comments

Comments
 (0)