Skip to content

Commit 45c4144

Browse files
committed
fix(studio): strip a group's GSAP when ungrouping
Ungrouping removed the wrapper element but left its gsap.set("#group-1") behind, targeting a now-deleted element. GSAP then threw "target not found" on every preview run, which drove a selection re-render storm that made canvas context menus (e.g. Delete All Keyframes) unclickable. unwrapElementsFromHtml now returns the unwrapped wrapper's id, and the unwrap route strips any GSAP animation targeting it (reusing the parser + removeAnimationFromScript).
1 parent 9df753a commit 45c4144

2 files changed

Lines changed: 51 additions & 8 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ export interface WrapElementsResult {
404404
export interface UnwrapElementsResult {
405405
html: string;
406406
unwrapped: boolean;
407+
/** The unwrapped wrapper's id, so callers can strip GSAP that targeted it
408+
* (the wrapper is gone; a leftover `gsap.set("#id")` would throw at runtime). */
409+
unwrappedGroupId?: string;
407410
}
408411

409412
export interface ElementRebase {
@@ -421,6 +424,23 @@ function getInlineStylePx(el: Element, property: string): number {
421424
return Number.isFinite(n) ? n : 0;
422425
}
423426

427+
// Slug the group name ("Group 1" → "group-1") into a unique, valid element id.
428+
function uniqueGroupDomId(document: Document, groupId: string): string {
429+
const base =
430+
groupId
431+
.trim()
432+
.toLowerCase()
433+
.replace(/[^a-z0-9]+/g, "-")
434+
.replace(/^-+|-+$/g, "") || "group";
435+
let id = base;
436+
let n = 2;
437+
while (document.getElementById(id)) {
438+
id = `${base}-${n}`;
439+
n += 1;
440+
}
441+
return id;
442+
}
443+
424444
function setInlineLeftTop(el: HTMLElement, left: number, top: number): void {
425445
let style = el.getAttribute("style") ?? "";
426446
style = patchStyleAttrString(style, "left", `${left}px`);
@@ -478,6 +498,10 @@ export function wrapElementsInHtml(
478498

479499
const wrapper = document.createElement("div");
480500
wrapper.setAttribute("data-hf-group", groupId);
501+
// A real `id` (slug of the group name) makes the wrapper a first-class node in the
502+
// clip manifest / timeline parent-map (both keyed by id) and a clean GSAP target —
503+
// without it the wrapper is invisible to the timeline and breaks child enumeration.
504+
wrapper.setAttribute("id", uniqueGroupDomId(document, groupId));
481505
wrapper.setAttribute(
482506
"style",
483507
`position: absolute; left: ${bbox.left}px; top: ${bbox.top}px; width: ${bbox.width}px; height: ${bbox.height}px`,
@@ -524,10 +548,12 @@ export function unwrapElementsFromHtml(
524548
}
525549
parent.insertBefore(child, group);
526550
}
551+
const groupId = group.id || undefined;
527552
group.remove();
528553

529554
return {
530555
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
531556
unwrapped: true,
557+
unwrappedGroupId: groupId,
532558
};
533559
}

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,25 @@ function extractGsapScriptBlock(html: string): {
314314
return null;
315315
}
316316

317+
/**
318+
* Remove every GSAP animation that targets `selector` from an HTML string's
319+
* inline script. Used after unwrapping a group so its leftover `gsap.set("#id")`
320+
* (the wrapper is gone) doesn't throw "target not found" on every preview run.
321+
*/
322+
function stripGsapAnimationsForSelector(html: string, selector: string): string {
323+
const block = extractGsapScriptBlock(html);
324+
if (!block) return html;
325+
const parsed = parseGsapScriptAcorn(block.scriptText);
326+
const matching = parsed.animations.filter((a) => a.targetSelector === selector);
327+
if (matching.length === 0) return html;
328+
let script = block.scriptText;
329+
// Reverse so earlier removals don't shift the spans of later ones.
330+
for (const anim of [...matching].reverse()) {
331+
script = removeAnimationFromScript(script, anim.id);
332+
}
333+
return block.replaceScript(script);
334+
}
335+
317336
function stripStudioEditsFromTarget(document: Document, selector: string): number {
318337
if (!selector) return 0;
319338
let stripped = 0;
@@ -1649,14 +1668,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
16491668
if (!result.unwrapped) {
16501669
return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
16511670
}
1652-
return writeIfChanged(
1653-
c,
1654-
ctx.project.dir,
1655-
ctx.filePath,
1656-
ctx.absPath,
1657-
originalContent,
1658-
result.html,
1659-
);
1671+
// The wrapper is gone — strip any GSAP that targeted it, or a leftover
1672+
// `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;
1676+
return writeIfChanged(c, ctx.project.dir, ctx.filePath, ctx.absPath, originalContent, cleaned);
16601677
});
16611678

16621679
api.post("/projects/:id/file-mutations/probe-element/*", async (c) => {

0 commit comments

Comments
 (0)