@@ -14,11 +14,16 @@ import {
1414 addKeyframeToScript ,
1515 removeKeyframeFromScript ,
1616 updateKeyframeInScript ,
17+ updateMotionPathPointInScript ,
18+ addMotionPathPointInScript ,
19+ removeMotionPathPointInScript ,
20+ addMotionPathToScript ,
1721 convertToKeyframesInScript ,
1822 removeAllKeyframesFromScript ,
1923 addAnimationWithKeyframesToScript ,
2024 splitAnimationsInScript ,
2125 splitIntoPropertyGroups ,
26+ syncPositionHoldsBeforeKeyframes ,
2227 shiftPositionsInScript ,
2328 scalePositionsInScript ,
2429} from "./gsapParser.js" ;
@@ -483,6 +488,12 @@ describe("property group classification", () => {
483488 ) ;
484489 } ) ;
485490
491+ it ( "ignores the internal `_auto` endpoint marker when classifying" , ( ) => {
492+ // Regression: the `_auto: 1` sentinel on auto-generated endpoint keyframes must
493+ // not pull a position tween into a mixed group, or drag-intercept can't resolve it.
494+ expect ( classifyTweenPropertyGroup ( { x : 100 , y : 50 , _auto : 1 } ) ) . toBe ( "position" ) ;
495+ } ) ;
496+
486497 it ( "returns undefined for mixed-group tweens" , ( ) => {
487498 expect ( classifyTweenPropertyGroup ( { x : 100 , scale : 0.5 } ) ) . toBeUndefined ( ) ;
488499 expect ( classifyTweenPropertyGroup ( { x : 100 , opacity : 0 } ) ) . toBeUndefined ( ) ;
@@ -1560,6 +1571,98 @@ describe("keyframe mutations", () => {
15601571 expect ( kfs [ 1 ] . properties . x ) . toBe ( 999 ) ;
15611572 } ) ;
15621573
1574+ // ── backfillDefaults: editing one keyframe must not move the others ──────
1575+ // UX invariant (CapCut/AE): keyframes are independent. Introducing a property
1576+ // to one keyframe (e.g. `y` on an x-only tween) must backfill the other
1577+ // keyframes at the element's base value — otherwise GSAP holds the new prop's
1578+ // value across keyframes that omit it, dragging them to the same position.
1579+ const X_ONLY_SCRIPT = `
1580+ const tl = gsap.timeline({ paused: true });
1581+ tl.to("#puck", { keyframes: { "0%": { x: 0 }, "100%": { x: -260 } }, duration: 2.2 }, 1.2);
1582+ ` ;
1583+
1584+ it ( "addKeyframeToScript — WITHOUT backfill, the other keyframe omits the new prop (GSAP would hold it)" , ( ) => {
1585+ const id = getAnimId ( X_ONLY_SCRIPT ) ;
1586+ const updated = addKeyframeToScript ( X_ONLY_SCRIPT , id , 0 , { x : 240 , y : 780 } ) ;
1587+ const kfs = parseGsapScript ( updated ) . animations [ 0 ] . keyframes ! . keyframes ;
1588+ const kf100 = kfs . find ( ( k ) => k . percentage === 100 ) ! ;
1589+ expect ( kf100 . properties . x ) . toBe ( - 260 ) ;
1590+ expect ( "y" in kf100 . properties ) . toBe ( false ) ; // <- the bug surface
1591+ } ) ;
1592+
1593+ it ( "addKeyframeToScript — WITH backfill, the new prop is added to the other keyframe at base (it stays put)" , ( ) => {
1594+ const id = getAnimId ( X_ONLY_SCRIPT ) ;
1595+ const updated = addKeyframeToScript ( X_ONLY_SCRIPT , id , 0 , { x : 240 , y : 780 } , undefined , {
1596+ x : 0 ,
1597+ y : 0 ,
1598+ } ) ;
1599+ const kfs = parseGsapScript ( updated ) . animations [ 0 ] . keyframes ! . keyframes ;
1600+ const kf0 = kfs . find ( ( k ) => k . percentage === 0 ) ! ;
1601+ const kf100 = kfs . find ( ( k ) => k . percentage === 100 ) ! ;
1602+ // edited keyframe holds the drag
1603+ expect ( kf0 . properties ) . toMatchObject ( { x : 240 , y : 780 } ) ;
1604+ // other keyframe keeps its own x and gets y at base (0) — not 780
1605+ expect ( kf100 . properties . x ) . toBe ( - 260 ) ;
1606+ expect ( kf100 . properties . y ) . toBe ( 0 ) ;
1607+ } ) ;
1608+
1609+ // ── syncPositionHoldsBeforeKeyframes (hold before first keyframe) ────────
1610+ // UX invariant (every NLE): before the first keyframe, the element holds that
1611+ // keyframe's value — it must NOT snap to its CSS base then jump when the tween
1612+ // starts. Implemented as a tagged `tl.set(...,0)` kept in sync with the tween.
1613+ describe ( "syncPositionHoldsBeforeKeyframes" , ( ) => {
1614+ const posTweenAt = ( start : number ) =>
1615+ `const tl = gsap.timeline({ paused: true });\n` +
1616+ `tl.to("#p", { keyframes: { "0%": { x: -1500, y: 700 }, "100%": { x: -260, y: 0 } }, duration: 2.2 }, ${ start } );` ;
1617+
1618+ it ( "inserts a hold set holding the first keyframe's position at t=0" , ( ) => {
1619+ const out = syncPositionHoldsBeforeKeyframes ( posTweenAt ( 1.2 ) ) ;
1620+ const anims = parseGsapScript ( out ) . animations ;
1621+ const hold = anims . find ( ( a ) => a . method === "set" ) ;
1622+ expect ( hold ) . toBeDefined ( ) ;
1623+ expect ( hold ! . position ) . toBe ( 0 ) ;
1624+ expect ( hold ! . properties ) . toMatchObject ( { x : - 1500 , y : 700 } ) ;
1625+ } ) ;
1626+
1627+ it ( "is idempotent (re-running does not stack holds)" , ( ) => {
1628+ const once = syncPositionHoldsBeforeKeyframes ( posTweenAt ( 1.2 ) ) ;
1629+ expect ( syncPositionHoldsBeforeKeyframes ( once ) ) . toBe ( once ) ;
1630+ expect ( ( once . match ( / h f - h o l d / g) ?? [ ] ) . length ) . toBe ( 1 ) ;
1631+ } ) ;
1632+
1633+ it ( "re-syncs the hold value when the first keyframe changes" , ( ) => {
1634+ const out1 = syncPositionHoldsBeforeKeyframes ( posTweenAt ( 1.2 ) ) ;
1635+ const moved = updateKeyframeInScript (
1636+ out1 ,
1637+ parseGsapScript ( out1 ) . animations . find ( ( a ) => a . keyframes ) ! . id ,
1638+ 0 ,
1639+ { x : 99 , y : 88 } ,
1640+ ) ;
1641+ const out2 = syncPositionHoldsBeforeKeyframes ( moved ) ;
1642+ const hold = parseGsapScript ( out2 ) . animations . find ( ( a ) => a . method === "set" ) ;
1643+ expect ( hold ! . properties ) . toMatchObject ( { x : 99 , y : 88 } ) ;
1644+ expect ( ( out2 . match ( / h f - h o l d / g) ?? [ ] ) . length ) . toBe ( 1 ) ; // still just one
1645+ } ) ;
1646+
1647+ it ( "adds no hold for a tween that already starts at t=0" , ( ) => {
1648+ expect ( syncPositionHoldsBeforeKeyframes ( posTweenAt ( 0 ) ) ) . not . toContain ( "hf-hold" ) ;
1649+ } ) ;
1650+
1651+ it ( "adds no hold for an opacity-only keyframed tween (position-scoped)" , ( ) => {
1652+ const opacity =
1653+ `const tl = gsap.timeline({ paused: true });\n` +
1654+ `tl.to("#b", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 1 }, 2);` ;
1655+ expect ( syncPositionHoldsBeforeKeyframes ( opacity ) ) . not . toContain ( "hf-hold" ) ;
1656+ } ) ;
1657+
1658+ it ( "removes an orphaned hold when its tween is gone" , ( ) => {
1659+ const withHold = syncPositionHoldsBeforeKeyframes ( posTweenAt ( 1.2 ) ) ;
1660+ const tweenId = parseGsapScript ( withHold ) . animations . find ( ( a ) => a . keyframes ) ! . id ;
1661+ const deleted = removeAnimationFromScript ( withHold , tweenId ) ;
1662+ expect ( syncPositionHoldsBeforeKeyframes ( deleted ) ) . not . toContain ( "hf-hold" ) ;
1663+ } ) ;
1664+ } ) ;
1665+
15631666 // ── _auto endpoint updates ────────────────────────────────────────────
15641667
15651668 const AUTO_SCRIPT = `
@@ -1681,6 +1784,204 @@ describe("keyframe mutations", () => {
16811784 expect ( kf100 . properties . y ) . toBe ( 50 ) ;
16821785 } ) ;
16831786
1787+ // Array-form keyframes (`keyframes: [{x,y}, …]`) carry no percentages — GSAP
1788+ // distributes them evenly. The motion-path overlay drags/adds by percentage,
1789+ // which used to no-op on array-authored tweens (#puck-b / #shuttle).
1790+ const ARRAY_KF_SCRIPT =
1791+ "const tl = gsap.timeline();\n" +
1792+ 'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);' ;
1793+
1794+ it ( "updateKeyframeInScript — array-form: drags node 2 (pct 33.3) by index" , ( ) => {
1795+ const id = getAnimId ( ARRAY_KF_SCRIPT ) ;
1796+ const updated = updateKeyframeInScript ( ARRAY_KF_SCRIPT , id , 33.3 , { x : 503 , y : 642 } ) ;
1797+ expect ( updated ) . not . toBe ( ARRAY_KF_SCRIPT ) ;
1798+ const kf = parseGsapScript ( updated ) . animations [ 0 ] . keyframes ! . keyframes ;
1799+ expect ( [ kf [ 1 ] ! . properties . x , kf [ 1 ] ! . properties . y ] ) . toEqual ( [ 503 , 642 ] ) ;
1800+ expect ( [ kf [ 0 ] ! . properties . x , kf [ 0 ] ! . properties . y ] ) . toEqual ( [ 0 , 0 ] ) ;
1801+ expect ( [ kf [ 2 ] ! . properties . x , kf [ 2 ] ! . properties . y ] ) . toEqual ( [ 1040 , 0 ] ) ;
1802+ } ) ;
1803+
1804+ it ( "addKeyframeToScript — array-form: normalizes to object form + inserts 50%" , ( ) => {
1805+ const id = getAnimId ( ARRAY_KF_SCRIPT ) ;
1806+ const updated = addKeyframeToScript ( ARRAY_KF_SCRIPT , id , 50 , { x : 780 , y : 60 } ) ;
1807+ expect ( updated ) . not . toBe ( ARRAY_KF_SCRIPT ) ;
1808+ const kf = parseGsapScript ( updated ) . animations [ 0 ] . keyframes ! . keyframes ;
1809+ expect ( kf . length ) . toBe ( 5 ) ;
1810+ const at50 = kf . find ( ( k ) => Math . abs ( k . percentage - 50 ) < 1 ) ! ;
1811+ expect ( [ at50 . properties . x , at50 . properties . y ] ) . toEqual ( [ 780 , 60 ] ) ;
1812+ } ) ;
1813+
1814+ it ( "removeKeyframeFromScript — array-form: drops node 3 (pct 66.7)" , ( ) => {
1815+ const id = getAnimId ( ARRAY_KF_SCRIPT ) ;
1816+ const updated = removeKeyframeFromScript ( ARRAY_KF_SCRIPT , id , 66.7 ) ;
1817+ expect ( updated ) . not . toBe ( ARRAY_KF_SCRIPT ) ;
1818+ const kf = parseGsapScript ( updated ) . animations [ 0 ] . keyframes ! . keyframes ;
1819+ expect ( kf . length ) . toBe ( 3 ) ;
1820+ } ) ;
1821+
1822+ it ( "updateKeyframeInScript — stale position-id resolves to the nearest same-selector tween" , ( ) => {
1823+ // Tween authored at 1.0s → id "#el-to-1000-position". A client that cached the
1824+ // pre-reposition id "#el-to-1200-position" (a gesture/convert moved it) must
1825+ // still resolve, instead of no-op'ing.
1826+ const script =
1827+ "const tl = gsap.timeline();\n" +
1828+ 'tl.to("#el", { keyframes: { "0%": { x: 0, y: 0 }, "100%": { x: 50, y: 50 } }, duration: 2 }, 1);' ;
1829+ const updated = updateKeyframeInScript ( script , "#el-to-1200-position" , 100 , { x : 77 , y : 88 } ) ;
1830+ expect ( updated ) . not . toBe ( script ) ;
1831+ const at100 = parseGsapScript ( updated ) . animations [ 0 ] . keyframes ! . keyframes . find (
1832+ ( k ) => k . percentage === 100 ,
1833+ ) ! ;
1834+ expect ( [ at100 . properties . x , at100 . properties . y ] ) . toEqual ( [ 77 , 88 ] ) ;
1835+ } ) ;
1836+
1837+ // ── updateMotionPathPointInScript ───────────────────────────────────────
1838+
1839+ const MOTION_PATH_SCRIPT = `
1840+ const tl = gsap.timeline({ paused: true });
1841+ tl.to("#el", {
1842+ motionPath: {
1843+ path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}],
1844+ curviness: 1.5
1845+ },
1846+ duration: 2
1847+ }, 0);
1848+ ` ;
1849+
1850+ it ( "updateMotionPathPointInScript — moves one waypoint, preserves the rest and curviness" , ( ) => {
1851+ const id = getAnimId ( MOTION_PATH_SCRIPT ) ;
1852+ const updated = updateMotionPathPointInScript ( MOTION_PATH_SCRIPT , id , 1 , { x : 250 , y : - 140 } ) ;
1853+ const reparsed = parseGsapScript ( updated ) ;
1854+ const anim = reparsed . animations [ 0 ] ;
1855+ const wp = anim . keyframes ! . keyframes ;
1856+ expect ( wp . map ( ( k ) => [ k . properties . x , k . properties . y ] ) ) . toEqual ( [
1857+ [ 0 , 0 ] ,
1858+ [ 250 , - 140 ] ,
1859+ [ 400 , 50 ] ,
1860+ ] ) ;
1861+ expect ( anim . arcPath ! . segments [ 0 ] . curviness ) . toBe ( 1.5 ) ;
1862+ expect ( anim . arcPath ! . segments [ 1 ] . curviness ) . toBe ( 1.5 ) ;
1863+ } ) ;
1864+
1865+ it ( "updateMotionPathPointInScript — out-of-range index leaves the script unchanged" , ( ) => {
1866+ const id = getAnimId ( MOTION_PATH_SCRIPT ) ;
1867+ expect ( updateMotionPathPointInScript ( MOTION_PATH_SCRIPT , id , 9 , { x : 1 , y : 1 } ) ) . toBe (
1868+ MOTION_PATH_SCRIPT ,
1869+ ) ;
1870+ } ) ;
1871+
1872+ it ( "updateMotionPathPointInScript — unknown animation id leaves the script unchanged" , ( ) => {
1873+ expect ( updateMotionPathPointInScript ( MOTION_PATH_SCRIPT , "nope" , 0 , { x : 1 , y : 1 } ) ) . toBe (
1874+ MOTION_PATH_SCRIPT ,
1875+ ) ;
1876+ } ) ;
1877+
1878+ it ( "updateMotionPathPointInScript — moves a cubic anchor, keeps control points" , ( ) => {
1879+ const script = `
1880+ const tl = gsap.timeline({ paused: true });
1881+ tl.to("#el", {
1882+ motionPath: {
1883+ path: [
1884+ {x: 0, y: 0},
1885+ {x: 50, y: -80}, {x: 150, y: -120},
1886+ {x: 200, y: -100}
1887+ ],
1888+ type: "cubic"
1889+ },
1890+ duration: 2
1891+ }, 0);
1892+ ` ;
1893+ const id = getAnimId ( script ) ;
1894+ const updated = updateMotionPathPointInScript ( script , id , 1 , { x : 220 , y : - 130 } ) ;
1895+ const reparsed = parseGsapScript ( updated ) ;
1896+ const anim = reparsed . animations [ 0 ] ;
1897+ // anchor 1 moved; the segment's control points are untouched.
1898+ expect ( anim . keyframes ! . keyframes [ 1 ] . properties ) . toMatchObject ( { x : 220 , y : - 130 } ) ;
1899+ expect ( anim . arcPath ! . segments [ 0 ] . cp1 ) . toEqual ( { x : 50 , y : - 80 } ) ;
1900+ expect ( anim . arcPath ! . segments [ 0 ] . cp2 ) . toEqual ( { x : 150 , y : - 120 } ) ;
1901+ } ) ;
1902+
1903+ // ── add/removeMotionPathPointInScript ───────────────────────────────────
1904+
1905+ it ( "addMotionPathPointInScript — inserts a waypoint between anchors, keeps curviness" , ( ) => {
1906+ const id = getAnimId ( MOTION_PATH_SCRIPT ) ;
1907+ const updated = addMotionPathPointInScript ( MOTION_PATH_SCRIPT , id , 1 , { x : 100 , y : - 50 } ) ;
1908+ const reparsed = parseGsapScript ( updated ) ;
1909+ const anim = reparsed . animations [ 0 ] ;
1910+ expect ( anim . keyframes ! . keyframes . map ( ( k ) => [ k . properties . x , k . properties . y ] ) ) . toEqual ( [
1911+ [ 0 , 0 ] ,
1912+ [ 100 , - 50 ] ,
1913+ [ 200 , - 100 ] ,
1914+ [ 400 , 50 ] ,
1915+ ] ) ;
1916+ // 4 anchors → 3 segments, all curviness 1.5
1917+ expect ( anim . arcPath ! . segments ) . toHaveLength ( 3 ) ;
1918+ expect ( anim . arcPath ! . segments . every ( ( s ) => s . curviness === 1.5 ) ) . toBe ( true ) ;
1919+ } ) ;
1920+
1921+ it ( "addMotionPathPointInScript — refuses an index at the ends or out of range" , ( ) => {
1922+ const id = getAnimId ( MOTION_PATH_SCRIPT ) ;
1923+ expect ( addMotionPathPointInScript ( MOTION_PATH_SCRIPT , id , 0 , { x : 1 , y : 1 } ) ) . toBe (
1924+ MOTION_PATH_SCRIPT ,
1925+ ) ;
1926+ expect ( addMotionPathPointInScript ( MOTION_PATH_SCRIPT , id , 3 , { x : 1 , y : 1 } ) ) . toBe (
1927+ MOTION_PATH_SCRIPT ,
1928+ ) ;
1929+ } ) ;
1930+
1931+ it ( "removeMotionPathPointInScript — drops a waypoint, preserves the rest" , ( ) => {
1932+ const id = getAnimId ( MOTION_PATH_SCRIPT ) ;
1933+ const updated = removeMotionPathPointInScript ( MOTION_PATH_SCRIPT , id , 1 ) ;
1934+ const reparsed = parseGsapScript ( updated ) ;
1935+ const anim = reparsed . animations [ 0 ] ;
1936+ expect ( anim . keyframes ! . keyframes . map ( ( k ) => [ k . properties . x , k . properties . y ] ) ) . toEqual ( [
1937+ [ 0 , 0 ] ,
1938+ [ 400 , 50 ] ,
1939+ ] ) ;
1940+ expect ( anim . arcPath ! . segments ) . toHaveLength ( 1 ) ;
1941+ } ) ;
1942+
1943+ it ( "removeMotionPathPointInScript — refuses to drop below two anchors" , ( ) => {
1944+ const two = `
1945+ const tl = gsap.timeline({ paused: true });
1946+ tl.to("#el", { motionPath: { path: [{x: 0, y: 0}, {x: 400, y: 50}], curviness: 1 }, duration: 2 }, 0);
1947+ ` ;
1948+ const id = getAnimId ( two ) ;
1949+ expect ( removeMotionPathPointInScript ( two , id , 0 ) ) . toBe ( two ) ;
1950+ } ) ;
1951+
1952+ it ( "add/removeMotionPathPointInScript — leave cubic paths untouched" , ( ) => {
1953+ const cubic = `
1954+ const tl = gsap.timeline({ paused: true });
1955+ tl.to("#el", { motionPath: { path: [{x:0,y:0},{x:50,y:-80},{x:150,y:-120},{x:200,y:-100}], type: "cubic" }, duration: 2 }, 0);
1956+ ` ;
1957+ const id = getAnimId ( cubic ) ;
1958+ expect ( addMotionPathPointInScript ( cubic , id , 1 , { x : 1 , y : 1 } ) ) . toBe ( cubic ) ;
1959+ expect ( removeMotionPathPointInScript ( cubic , id , 1 ) ) . toBe ( cubic ) ;
1960+ } ) ;
1961+
1962+ // ── addMotionPathToScript ───────────────────────────────────────────────
1963+
1964+ it ( "addMotionPathToScript — authors a new 2-anchor motionPath tween" , ( ) => {
1965+ const script = `
1966+ const tl = gsap.timeline({ paused: true });
1967+ tl.from("#title", { opacity: 0, duration: 0.5 }, 0);
1968+ ` ;
1969+ const { script : updated , id } = addMotionPathToScript ( script , "#el" , 2.0 , 1.5 , {
1970+ x : 300 ,
1971+ y : - 100 ,
1972+ } ) ;
1973+ expect ( id ) . not . toBe ( "" ) ;
1974+ const reparsed = parseGsapScript ( updated ) ;
1975+ const anim = reparsed . animations . find ( ( a ) => a . targetSelector === "#el" ) ! ;
1976+ expect ( anim ) . toBeDefined ( ) ;
1977+ expect ( anim . arcPath ! . enabled ) . toBe ( true ) ;
1978+ expect ( anim . keyframes ! . keyframes . map ( ( k ) => [ k . properties . x , k . properties . y ] ) ) . toEqual ( [
1979+ [ 0 , 0 ] ,
1980+ [ 300 , - 100 ] ,
1981+ ] ) ;
1982+ expect ( anim . duration ) . toBe ( 1.5 ) ;
1983+ } ) ;
1984+
16841985 // ── convertToKeyframesInScript ──────────────────────────────────────────
16851986
16861987 it ( "convertToKeyframesInScript — converts flat to() tween" , ( ) => {
@@ -1984,6 +2285,19 @@ describe("splitAnimationsInScript", () => {
19842285 expect ( forNew [ 0 ] ! . position ) . toBe ( opts . splitTime ) ;
19852286 } ) ;
19862287
2288+ it ( "does not pin the clone to from-values for a completed .from() before the split" , ( ) => {
2289+ // A .from() that finished before the split leaves the element at its natural
2290+ // state. Carrying its from-values (opacity:0) into the clone's `set` made the
2291+ // clone invisible. The clone should get NO inherited set for those props.
2292+ const script = `${ baseScript } \ntl.from("#el1", { y: 70, opacity: 0, duration: 0.9 }, 0.4);` ;
2293+ const result = split ( script ) ;
2294+ const parsed = parseGsapScript ( result ) ;
2295+ const forNew = parsed . animations . filter ( ( a ) => a . targetSelector === "#el1-split" ) ;
2296+ const inheritedSet = forNew . find ( ( a ) => a . method === "set" ) ;
2297+ expect ( inheritedSet ) . toBeUndefined ( ) ;
2298+ expect ( result ) . not . toContain ( "#el1-split" ) ;
2299+ } ) ;
2300+
19872301 it ( "retargets animation entirely in second half to new element" , ( ) => {
19882302 const script = `${ baseScript } \ntl.to("#el1", { x: 100, duration: 1 }, 3);` ;
19892303 const selectors = parseSplitAndAssert ( script , ( s ) => split ( s ) , 1 ) ;
0 commit comments