@@ -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+
336420function 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