mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user