@@ -24,6 +24,7 @@ import {
2424 type UnsafeMutationValue ,
2525} from "../helpers/finiteMutation.js" ;
2626import type { GsapAnimation } from "../../parsers/gsapSerialize.js" ;
27+ import { classifyPropertyGroup } from "../../parsers/gsapConstants.js" ;
2728import { parseGsapScriptAcorn } from "../../parsers/gsapParserAcorn.js" ;
2829import { unrollComputedTimeline } from "../../parsers/gsapUnroll.js" ;
2930import {
@@ -289,6 +290,18 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe
289290 return stripped ;
290291}
291292
293+ // A studio path-offset (--hf-studio-offset / data-hf-studio-path-offset) and a GSAP
294+ // position tween both drive translate — keeping both stacks the offsets (a gesture or
295+ // drag recorded over a stale offset plays shoved off-position). When a committed tween
296+ // writes a position property, the tween owns position, so the stale offset must go.
297+ function keyframesWritePosition (
298+ keyframes : Array < { properties : Record < string , number | string > } > ,
299+ ) : boolean {
300+ return keyframes . some ( ( kf ) =>
301+ Object . keys ( kf . properties ) . some ( ( k ) => classifyPropertyGroup ( k ) === "position" ) ,
302+ ) ;
303+ }
304+
292305function lastKeyframeOpacity ( kfs : GsapAnimation [ "keyframes" ] ) : number | string | undefined {
293306 if ( ! kfs ) return undefined ;
294307 for ( let i = kfs . keyframes . length - 1 ; i >= 0 ; i -- ) {
@@ -431,6 +444,24 @@ type GsapMutationRequest =
431444 cp1 ?: { x : number ; y : number } ;
432445 cp2 ?: { x : number ; y : number } ;
433446 }
447+ | {
448+ type : "update-motion-path-point" ;
449+ animationId : string ;
450+ pointIndex : number ;
451+ x : number ;
452+ y : number ;
453+ }
454+ | { type : "add-motion-path-point" ; animationId : string ; index : number ; x : number ; y : number }
455+ | { type : "remove-motion-path-point" ; animationId : string ; index : number }
456+ | {
457+ type : "add-motion-path" ;
458+ targetSelector : string ;
459+ position : number ;
460+ duration : number ;
461+ x : number ;
462+ y : number ;
463+ ease ?: string ;
464+ }
434465 | { type : "remove-arc-path" ; animationId : string }
435466 | {
436467 type : "add-with-keyframes" ;
@@ -498,6 +529,24 @@ type GsapMutationRequest =
498529
499530type GsapMutationResult = string | { script : string ; skippedSelectors : string [ ] } ;
500531
532+ // Mutations that can change a position tween's first keyframe (value/existence/timing)
533+ // and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards.
534+ const HOLD_SYNC_MUTATION_TYPES = new Set < string > ( [
535+ "add-keyframe" ,
536+ "update-keyframe" ,
537+ "remove-keyframe" ,
538+ "remove-all-keyframes" ,
539+ "add-with-keyframes" ,
540+ "replace-with-keyframes" ,
541+ "convert-to-keyframes" ,
542+ "materialize-keyframes" ,
543+ "update-motion-path-point" ,
544+ "add-motion-path-point" ,
545+ "remove-motion-path-point" ,
546+ "delete" ,
547+ "delete-all-for-selector" ,
548+ ] ) ;
549+
501550async function executeGsapMutation (
502551 body : GsapMutationRequest ,
503552 block : NonNullable < ReturnType < typeof extractGsapScriptBlock > > ,
@@ -517,6 +566,10 @@ async function executeGsapMutation(
517566 unrollDynamicAnimations,
518567 setArcPathInScript,
519568 updateArcSegmentInScript,
569+ updateMotionPathPointInScript,
570+ addMotionPathPointInScript,
571+ removeMotionPathPointInScript,
572+ addMotionPathToScript,
520573 removeArcPathFromScript,
521574 addAnimationWithKeyframesToScript,
522575 splitAnimationsInScript,
@@ -680,10 +733,39 @@ async function executeGsapMutation(
680733 ...( body . cp2 ? { cp2 : body . cp2 } : { } ) ,
681734 } ) ;
682735 }
736+ case "update-motion-path-point" : {
737+ return updateMotionPathPointInScript ( block . scriptText , body . animationId , body . pointIndex , {
738+ x : body . x ,
739+ y : body . y ,
740+ } ) ;
741+ }
742+ case "add-motion-path-point" : {
743+ return addMotionPathPointInScript ( block . scriptText , body . animationId , body . index , {
744+ x : body . x ,
745+ y : body . y ,
746+ } ) ;
747+ }
748+ case "remove-motion-path-point" : {
749+ return removeMotionPathPointInScript ( block . scriptText , body . animationId , body . index ) ;
750+ }
751+ case "add-motion-path" : {
752+ const result = addMotionPathToScript (
753+ block . scriptText ,
754+ body . targetSelector ,
755+ body . position ,
756+ body . duration ,
757+ { x : body . x , y : body . y } ,
758+ body . ease ,
759+ ) ;
760+ return result . script ;
761+ }
683762 case "remove-arc-path" : {
684763 return removeArcPathFromScript ( block . scriptText , body . animationId ) ;
685764 }
686765 case "add-with-keyframes" : {
766+ if ( keyframesWritePosition ( body . keyframes ) ) {
767+ stripStudioEditsFromTarget ( block . document , body . targetSelector ) ;
768+ }
687769 const result = addAnimationWithKeyframesToScript (
688770 block . scriptText ,
689771 body . targetSelector ,
@@ -695,6 +777,9 @@ async function executeGsapMutation(
695777 return result . script ;
696778 }
697779 case "replace-with-keyframes" : {
780+ if ( keyframesWritePosition ( body . keyframes ) ) {
781+ stripStudioEditsFromTarget ( block . document , body . targetSelector ) ;
782+ }
698783 const script = removeAnimationFromScript ( block . scriptText , body . animationId ) ;
699784 const added = addAnimationWithKeyframesToScript (
700785 script ,
@@ -970,11 +1055,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
9701055 target ?: { id ?: string ; selector ?: string ; selectorIndex ?: number } ;
9711056 splitTime ?: number ;
9721057 newId ?: string ;
1058+ elementStart ?: number ;
1059+ elementDuration ?: number ;
9731060 } > ( c ) ;
9741061 if ( "error" in parsed ) return parsed . error ;
9751062 if ( typeof parsed . body . splitTime !== "number" || ! parsed . body . newId ) {
9761063 return c . json ( { error : "target, splitTime, and newId required" } , 400 ) ;
9771064 }
1065+ const fallbackTiming =
1066+ typeof parsed . body . elementStart === "number" &&
1067+ typeof parsed . body . elementDuration === "number"
1068+ ? { start : parsed . body . elementStart , duration : parsed . body . elementDuration }
1069+ : undefined ;
9781070
9791071 let originalContent : string ;
9801072 try {
@@ -987,6 +1079,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
9871079 parsed . target ,
9881080 parsed . body . splitTime ,
9891081 parsed . body . newId ,
1082+ fallbackTiming ,
9901083 ) ;
9911084 if ( ! result . matched ) {
9921085 return c . json ( { ok : false , changed : false , content : originalContent , path : ctx . filePath } ) ;
@@ -1230,7 +1323,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
12301323 const result = await executeGsapMutation ( body , block , respond ) ;
12311324 if ( result instanceof Response ) return result ;
12321325
1233- const newScript = typeof result === "string" ? result : result . script ;
1326+ let newScript = typeof result === "string" ? result : result . script ;
1327+ // Keep the "hold before first keyframe" sets in sync after any mutation that can
1328+ // change a position tween's first keyframe or its existence. Without it, an
1329+ // element snaps to its CSS base before the tween starts instead of holding its
1330+ // first keyframe (the universal NLE behavior).
1331+ if ( HOLD_SYNC_MUTATION_TYPES . has ( body . type ) ) {
1332+ const parser = await loadGsapParser ( ) ;
1333+ newScript = parser . syncPositionHoldsBeforeKeyframes ( newScript ) ;
1334+ }
12341335 const changed = newScript !== block . scriptText ;
12351336 const newHtml = changed ? block . replaceScript ( newScript ) : html ;
12361337 let backupPath : string | null = null ;
0 commit comments