Skip to content

Commit 4ee9b9f

Browse files
committed
feat(core): add GSAP keyframe + motion-path source mutations
Array-form keyframe removal in both the recast and acorn writers, plus update/add/remove-motion-path-point and add-motion-path. Exclude _auto and data from tween property-group classification.
1 parent cd4308b commit 4ee9b9f

7 files changed

Lines changed: 907 additions & 8 deletions

File tree

packages/core/src/parsers/gsapConstants.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ export function classifyTweenPropertyGroup(
7777
): PropertyGroupName | undefined {
7878
const groups = new Set<PropertyGroupName>();
7979
for (const key of Object.keys(properties)) {
80-
if (key === "transformOrigin") continue;
80+
// transformOrigin is a modifier; `_auto` is Studio's internal endpoint marker;
81+
// `data` is GSAP-reserved (carries the Studio hold-set tag). None is an animated
82+
// property, so none should affect the group.
83+
if (key === "transformOrigin" || key === "_auto" || key === "data") continue;
8184
const g = classifyPropertyGroup(key);
8285
groups.add(g);
8386
}

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

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/hf-hold/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(/hf-hold/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

Comments
 (0)