Skip to content

Commit 96b8d61

Browse files
feat(studio): GSAP parser — arc path mutations + keyframe CRUD (#1301)
Add parser-level mutations for arc paths, keyframe add/remove/update, convert-to-keyframes, and _auto flag for 100% keyframes. Wire route handlers for new mutation types.
1 parent 0923bc0 commit 96b8d61

5 files changed

Lines changed: 718 additions & 6 deletions

File tree

packages/core/src/parsers/gsapConstants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,5 @@ export const SUPPORTED_EASES = [
7676
"spring-stiff",
7777
"spring-wobbly",
7878
"spring-heavy",
79+
"steps(1)",
7980
];

packages/core/src/parsers/gsapParser.test.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
updateKeyframeInScript,
1717
convertToKeyframesInScript,
1818
removeAllKeyframesFromScript,
19+
addAnimationWithKeyframesToScript,
1920
} from "./gsapParser.js";
2021
import type { GsapAnimation } from "./gsapParser.js";
2122
import type { Keyframe } from "../core.types";
@@ -1504,3 +1505,202 @@ describe("keyframe mutations", () => {
15041505
expect(anim.properties.opacity).toBe(1);
15051506
});
15061507
});
1508+
1509+
describe("motionPath parsing", () => {
1510+
it("parses motionPath with waypoint array and curviness", () => {
1511+
const script = `
1512+
const tl = gsap.timeline({ paused: true });
1513+
tl.to("#el", {
1514+
motionPath: {
1515+
path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}],
1516+
curviness: 1.5
1517+
},
1518+
duration: 2
1519+
}, 0);
1520+
`;
1521+
const result = parseGsapScript(script);
1522+
expect(result.animations).toHaveLength(1);
1523+
const anim = result.animations[0];
1524+
1525+
expect(anim.arcPath).toBeDefined();
1526+
expect(anim.arcPath!.enabled).toBe(true);
1527+
expect(anim.arcPath!.segments).toHaveLength(2);
1528+
expect(anim.arcPath!.segments[0].curviness).toBe(1.5);
1529+
expect(anim.arcPath!.segments[1].curviness).toBe(1.5);
1530+
1531+
expect(anim.keyframes).toBeDefined();
1532+
expect(anim.keyframes!.keyframes).toHaveLength(3);
1533+
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
1534+
expect(anim.keyframes!.keyframes[0].properties.y).toBe(0);
1535+
expect(anim.keyframes!.keyframes[1].properties.x).toBe(200);
1536+
expect(anim.keyframes!.keyframes[1].properties.y).toBe(-100);
1537+
expect(anim.keyframes!.keyframes[2].properties.x).toBe(400);
1538+
expect(anim.keyframes!.keyframes[2].properties.y).toBe(50);
1539+
});
1540+
1541+
it("parses motionPath with type cubic and explicit control points", () => {
1542+
const script = `
1543+
const tl = gsap.timeline({ paused: true });
1544+
tl.to("#el", {
1545+
motionPath: {
1546+
path: [
1547+
{x: 0, y: 0},
1548+
{x: 50, y: -80}, {x: 150, y: -120},
1549+
{x: 200, y: -100},
1550+
{x: 250, y: -80}, {x: 350, y: 30},
1551+
{x: 400, y: 50}
1552+
],
1553+
type: "cubic"
1554+
},
1555+
duration: 2
1556+
}, 0);
1557+
`;
1558+
const result = parseGsapScript(script);
1559+
const anim = result.animations[0];
1560+
1561+
expect(anim.arcPath).toBeDefined();
1562+
expect(anim.arcPath!.segments).toHaveLength(2);
1563+
1564+
expect(anim.arcPath!.segments[0].cp1).toEqual({ x: 50, y: -80 });
1565+
expect(anim.arcPath!.segments[0].cp2).toEqual({ x: 150, y: -120 });
1566+
1567+
expect(anim.arcPath!.segments[1].cp1).toEqual({ x: 250, y: -80 });
1568+
expect(anim.arcPath!.segments[1].cp2).toEqual({ x: 350, y: 30 });
1569+
1570+
expect(anim.keyframes!.keyframes).toHaveLength(3);
1571+
expect(anim.keyframes!.keyframes[0].properties).toEqual({ x: 0, y: 0 });
1572+
expect(anim.keyframes!.keyframes[1].properties).toEqual({ x: 200, y: -100 });
1573+
expect(anim.keyframes!.keyframes[2].properties).toEqual({ x: 400, y: 50 });
1574+
});
1575+
1576+
it("parses motionPath with autoRotate", () => {
1577+
const script = `
1578+
const tl = gsap.timeline({ paused: true });
1579+
tl.to("#el", {
1580+
motionPath: {
1581+
path: [{x: 0, y: 0}, {x: 200, y: 100}],
1582+
autoRotate: true
1583+
},
1584+
duration: 1
1585+
}, 0);
1586+
`;
1587+
const result = parseGsapScript(script);
1588+
const anim = result.animations[0];
1589+
expect(anim.arcPath!.autoRotate).toBe(true);
1590+
});
1591+
1592+
it("merges motionPath waypoints into existing keyframes", () => {
1593+
const script = `
1594+
const tl = gsap.timeline({ paused: true });
1595+
tl.to("#el", {
1596+
motionPath: {
1597+
path: [{x: 0, y: 0}, {x: 200, y: 100}],
1598+
curviness: 2
1599+
},
1600+
keyframes: {
1601+
"0%": { opacity: 1 },
1602+
"100%": { opacity: 0 }
1603+
},
1604+
duration: 2
1605+
}, 0);
1606+
`;
1607+
const result = parseGsapScript(script);
1608+
const anim = result.animations[0];
1609+
1610+
expect(anim.arcPath).toBeDefined();
1611+
expect(anim.arcPath!.segments).toHaveLength(1);
1612+
expect(anim.arcPath!.segments[0].curviness).toBe(2);
1613+
1614+
expect(anim.keyframes!.keyframes).toHaveLength(2);
1615+
expect(anim.keyframes!.keyframes[0].properties).toEqual({ opacity: 1, x: 0, y: 0 });
1616+
expect(anim.keyframes!.keyframes[1].properties).toEqual({ opacity: 0, x: 200, y: 100 });
1617+
});
1618+
1619+
it("skips motionPath with fewer than 2 waypoints", () => {
1620+
const script = `
1621+
const tl = gsap.timeline({ paused: true });
1622+
tl.to("#el", {
1623+
motionPath: { path: [{x: 0, y: 0}] },
1624+
duration: 1
1625+
}, 0);
1626+
`;
1627+
const result = parseGsapScript(script);
1628+
expect(result.animations[0].arcPath).toBeUndefined();
1629+
});
1630+
1631+
it("tween without motionPath parses identically to before", () => {
1632+
const script = `
1633+
const tl = gsap.timeline({ paused: true });
1634+
tl.to("#el", { x: 100, y: 200, duration: 1 }, 0);
1635+
`;
1636+
const result = parseGsapScript(script);
1637+
const anim = result.animations[0];
1638+
expect(anim.arcPath).toBeUndefined();
1639+
expect(anim.properties.x).toBe(100);
1640+
expect(anim.properties.y).toBe(200);
1641+
});
1642+
});
1643+
1644+
// ── addAnimationWithKeyframesToScript ──────────────────────────────────────
1645+
1646+
describe("addAnimationWithKeyframesToScript", () => {
1647+
const BASE = `
1648+
const tl = gsap.timeline({ paused: true });
1649+
tl.to("#title", { x: 100, duration: 0.5 }, 0);
1650+
`.trim();
1651+
1652+
it("adds a new tween with keyframes after existing tweens", () => {
1653+
const { script, id } = addAnimationWithKeyframesToScript(BASE, "#box", 3, 0.5, [
1654+
{ percentage: 0, properties: { x: 0 } },
1655+
{ percentage: 100, properties: { x: 200 } },
1656+
]);
1657+
expect(script).toContain("#box");
1658+
expect(script).toContain("keyframes");
1659+
expect(script).toContain('"0%"');
1660+
expect(script).toContain('"100%"');
1661+
expect(id).toBeTruthy();
1662+
1663+
const parsed = parseGsapScript(script);
1664+
expect(parsed.animations.length).toBe(2);
1665+
const newAnim = parsed.animations[1];
1666+
expect(newAnim.targetSelector).toBe("#box");
1667+
expect(newAnim.keyframes).toBeDefined();
1668+
expect(newAnim.keyframes!.keyframes.length).toBe(2);
1669+
});
1670+
1671+
it("preserves existing tween code", () => {
1672+
const { script } = addAnimationWithKeyframesToScript(BASE, "#new", 2, 1, [
1673+
{ percentage: 0, properties: { opacity: 0 } },
1674+
{ percentage: 100, properties: { opacity: 1 } },
1675+
]);
1676+
expect(script).toContain("#title");
1677+
expect(script).toContain("x: 100");
1678+
});
1679+
1680+
it("produces a stable ID for the new animation", () => {
1681+
const { script, id } = addAnimationWithKeyframesToScript(BASE, "#el", 1, 1, [
1682+
{ percentage: 0, properties: { y: 0 } },
1683+
{ percentage: 100, properties: { y: 100 } },
1684+
]);
1685+
expect(id).toContain("#el");
1686+
const parsed = parseGsapScript(script);
1687+
const match = parsed.animations.find((a) => a.id === id);
1688+
expect(match).toBeDefined();
1689+
});
1690+
1691+
it("includes per-keyframe ease when provided", () => {
1692+
const { script } = addAnimationWithKeyframesToScript(BASE, "#el", 0, 1, [
1693+
{ percentage: 0, properties: { x: 0 }, ease: "power2.out" },
1694+
{ percentage: 100, properties: { x: 100 } },
1695+
]);
1696+
expect(script).toContain("power2.out");
1697+
});
1698+
1699+
it("returns original script on parse failure", () => {
1700+
const { script, id } = addAnimationWithKeyframesToScript("not valid js {{", "#el", 0, 1, [
1701+
{ percentage: 0, properties: { x: 0 } },
1702+
]);
1703+
expect(script).toBe("not valid js {{");
1704+
expect(id).toBe("");
1705+
});
1706+
});

0 commit comments

Comments
 (0)