From 96f2c9a0c2f58606437b9c9ba2f624785c260691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 11 Jun 2026 19:13:30 -0400 Subject: [PATCH] fix(core): per-property-group keyframe foundations Add PropertyGroupName type system (position/scale/size/rotation/visual/other), PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup functions. Parser generates group-aware animation IDs, resolves position strings (+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs across all mutations. --- .../parsers/__goldens__/complex.parsed.json | 53 ++- .../parsers/__goldens__/complex.serialized.js | 6 +- .../parsers/__goldens__/fromto.parsed.json | 2 + .../parsers/__goldens__/minimal.parsed.json | 2 + .../parsers/__goldens__/moderate.parsed.json | 18 +- packages/core/src/parsers/gsapConstants.ts | 40 ++ .../src/parsers/gsapParser.stress.test.ts | 8 +- packages/core/src/parsers/gsapParser.test.ts | 351 +++++++++++++- packages/core/src/parsers/gsapParser.ts | 428 ++++++++++++++++-- packages/core/src/parsers/gsapSerialize.ts | 14 +- 10 files changed, 835 insertions(+), 87 deletions(-) diff --git a/packages/core/src/parsers/__goldens__/complex.parsed.json b/packages/core/src/parsers/__goldens__/complex.parsed.json index 92c5def44..af592ef9d 100644 --- a/packages/core/src/parsers/__goldens__/complex.parsed.json +++ b/packages/core/src/parsers/__goldens__/complex.parsed.json @@ -1,55 +1,62 @@ { "animations": [ { - "targetSelector": ".ambient-line", + "targetSelector": ".headline span", "method": "from", - "position": 0.16, + "position": 0.05, "properties": { - "scaleX": 0, + "y": 46, "opacity": 0 }, - "duration": 0.42, + "duration": 0.38, + "ease": "back.out(1.35)", "extras": { - "stagger": "__raw:0.08" + "stagger": "__raw:0.055" }, - "id": ".ambient-line-from-160" + "resolvedStart": 0.05, + "id": ".headline span-from-50" }, { - "targetSelector": ".ambient-word", + "targetSelector": ".headline .sub", "method": "from", - "position": 0.08, + "position": 0.2, "properties": { - "scale": 0.92, + "y": 20, "opacity": 0 }, - "duration": 0.5, - "id": ".ambient-word-from-80" + "duration": 0.28, + "ease": "power3.out", + "resolvedStart": 0.2, + "id": ".headline .sub-from-200" }, { - "targetSelector": ".headline .sub", + "targetSelector": ".ambient-word", "method": "from", - "position": 0.2, + "position": 0.08, "properties": { - "y": 20, + "scale": 0.92, "opacity": 0 }, - "duration": 0.28, - "id": ".headline .sub-from-200" + "duration": 0.5, + "ease": "power3.out", + "resolvedStart": 0.08, + "id": ".ambient-word-from-80" }, { - "targetSelector": ".headline span", + "targetSelector": ".ambient-line", "method": "from", - "position": 0.05, + "position": 0.16, "properties": { - "y": 46, + "scaleX": 0, "opacity": 0 }, - "duration": 0.38, - "ease": "back.out(1.35)", + "duration": 0.42, + "ease": "power3.out", "extras": { - "stagger": "__raw:0.055" + "stagger": "__raw:0.08" }, - "id": ".headline span-from-50" + "resolvedStart": 0.16, + "id": ".ambient-line-from-160" } ], "timelineVar": "tl", diff --git a/packages/core/src/parsers/__goldens__/complex.serialized.js b/packages/core/src/parsers/__goldens__/complex.serialized.js index 2a1516fee..16e8313db 100644 --- a/packages/core/src/parsers/__goldens__/complex.serialized.js +++ b/packages/core/src/parsers/__goldens__/complex.serialized.js @@ -3,8 +3,8 @@ gsap.defaults({ force3D: true }); const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } }); tl.from(".headline span", { y: 46, opacity: 0, duration: 0.38, ease: "back.out(1.35)", stagger: 0.055 }, 0.05); - tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08); - tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, stagger: 0.08 }, 0.16); - tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2); + tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5, ease: "power3.out" }, 0.08); + tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, ease: "power3.out", stagger: 0.08 }, 0.16); + tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28, ease: "power3.out" }, 0.2); window.__timelines["vpn-youtube-spot"] = tl; \ No newline at end of file diff --git a/packages/core/src/parsers/__goldens__/fromto.parsed.json b/packages/core/src/parsers/__goldens__/fromto.parsed.json index 1431064c3..8c8637d49 100644 --- a/packages/core/src/parsers/__goldens__/fromto.parsed.json +++ b/packages/core/src/parsers/__goldens__/fromto.parsed.json @@ -14,6 +14,7 @@ }, "duration": 0.6, "ease": "power3.out", + "resolvedStart": 0.1, "id": "#hero-fromTo-100" }, { @@ -29,6 +30,7 @@ "opacity": 0 }, "duration": 0.45, + "resolvedStart": 0.5, "id": "#caption-fromTo-500" } ], diff --git a/packages/core/src/parsers/__goldens__/minimal.parsed.json b/packages/core/src/parsers/__goldens__/minimal.parsed.json index f20fb8fb7..cbfa33de9 100644 --- a/packages/core/src/parsers/__goldens__/minimal.parsed.json +++ b/packages/core/src/parsers/__goldens__/minimal.parsed.json @@ -10,6 +10,7 @@ }, "duration": 0.5, "ease": "power3.out", + "resolvedStart": 0.2, "id": "#notification-to-200" }, { @@ -22,6 +23,7 @@ }, "duration": 0.3, "ease": "power3.in", + "resolvedStart": 4.2, "id": "#notification-to-4200" } ], diff --git a/packages/core/src/parsers/__goldens__/moderate.parsed.json b/packages/core/src/parsers/__goldens__/moderate.parsed.json index 1c4e39127..8edb3f350 100644 --- a/packages/core/src/parsers/__goldens__/moderate.parsed.json +++ b/packages/core/src/parsers/__goldens__/moderate.parsed.json @@ -10,6 +10,7 @@ }, "duration": 0.5, "ease": "power3.out", + "resolvedStart": 0.1, "id": "#card-to-100" }, { @@ -21,7 +22,9 @@ }, "duration": 0.15, "ease": "power2.out", - "id": "#subscribe-btn-to-1000" + "propertyGroup": "scale", + "resolvedStart": 1, + "id": "#subscribe-btn-to-1000-scale" }, { "targetSelector": "#subscribe-btn", @@ -32,7 +35,9 @@ }, "duration": 0.4, "ease": "elastic.out(1, 0.4)", - "id": "#subscribe-btn-to-1150" + "propertyGroup": "scale", + "resolvedStart": 1.15, + "id": "#subscribe-btn-to-1150-scale" }, { "targetSelector": "#btn-subscribe", @@ -43,7 +48,9 @@ }, "duration": 0.08, "ease": "none", - "id": "#btn-subscribe-to-1150" + "propertyGroup": "visual", + "resolvedStart": 1.15, + "id": "#btn-subscribe-to-1150-visual" }, { "targetSelector": "#btn-subscribed", @@ -54,7 +61,9 @@ }, "duration": 0.08, "ease": "none", - "id": "#btn-subscribed-to-1180" + "propertyGroup": "visual", + "resolvedStart": 1.18, + "id": "#btn-subscribed-to-1180-visual" }, { "targetSelector": "#card", @@ -66,6 +75,7 @@ }, "duration": 0.25, "ease": "power3.in", + "resolvedStart": 3.8, "id": "#card-to-3800" } ], diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 9cae4cc9f..015362395 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -45,6 +45,46 @@ export const SUPPORTED_PROPS = [ "innerText", ]; +// ── Property Groups ───────────────────────────────────────────────────────── +// Each group maps to an independent GSAP tween so editing one property +// (e.g. drag → x/y) never contaminates another (e.g. scale, rotation). + +export type PropertyGroupName = "position" | "scale" | "size" | "rotation" | "visual" | "other"; + +export const PROPERTY_GROUPS: Record> = { + position: new Set(["x", "y", "xPercent", "yPercent"]), + scale: new Set(["scale", "scaleX", "scaleY"]), + size: new Set(["width", "height"]), + rotation: new Set(["rotation", "skewX", "skewY"]), + visual: new Set(["opacity", "autoAlpha"]), + other: new Set(), +}; + +const PROP_TO_GROUP = new Map(); +for (const [group, props] of Object.entries(PROPERTY_GROUPS) as [ + PropertyGroupName, + ReadonlySet, +][]) { + for (const p of props) PROP_TO_GROUP.set(p, group); +} + +export function classifyPropertyGroup(prop: string): PropertyGroupName { + return PROP_TO_GROUP.get(prop) ?? "other"; +} + +export function classifyTweenPropertyGroup( + properties: Record, +): PropertyGroupName | undefined { + const groups = new Set(); + for (const key of Object.keys(properties)) { + if (key === "transformOrigin") continue; + const g = classifyPropertyGroup(key); + groups.add(g); + } + if (groups.size === 1) return groups.values().next().value; + return undefined; +} + export const SUPPORTED_EASES = [ "none", "power1.in", diff --git a/packages/core/src/parsers/gsapParser.stress.test.ts b/packages/core/src/parsers/gsapParser.stress.test.ts index 95c1ef047..c99b88415 100644 --- a/packages/core/src/parsers/gsapParser.stress.test.ts +++ b/packages/core/src/parsers/gsapParser.stress.test.ts @@ -658,9 +658,9 @@ describe("14. ID collision", () => { const ids = result.animations.map((a) => a.id); // All IDs must be unique expect(new Set(ids).size).toBe(3); - expect(ids[0]).toBe("#el-to-0"); - expect(ids[1]).toBe("#el-to-0-2"); - expect(ids[2]).toBe("#el-to-0-3"); + expect(ids[0]).toBe("#el-to-0-visual"); + expect(ids[1]).toBe("#el-to-0-position"); + expect(ids[2]).toBe("#el-to-0-position-2"); }); it("disambiguated IDs are stable across parses", () => { @@ -932,7 +932,7 @@ describe("Additional edge cases", () => { `; const result = parseGsapScript(script); // ID uses Math.round(position * 1000) for numeric positions - expect(result.animations[0].id).toBe("#el-to--2500"); + expect(result.animations[0].id).toBe("#el-to--2500-position"); }); it("fromTo with no position arg defaults to 0", () => { diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 85c204118..0ef8797b6 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -18,8 +18,10 @@ import { removeAllKeyframesFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, + splitIntoPropertyGroups, } from "./gsapParser.js"; import type { GsapAnimation } from "./gsapParser.js"; +import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js"; import type { Keyframe } from "../core.types"; import { parseAndSerialize, @@ -260,8 +262,8 @@ describe("parseGsapScript", () => { expect(result1.animations[1].id).toBe(result2.animations[1].id); // IDs encode selector, method, and position - expect(result1.animations[0].id).toBe("#el1-to-0"); - expect(result1.animations[1].id).toBe("#el2-to-1000"); + expect(result1.animations[0].id).toBe("#el1-to-0-visual"); + expect(result1.animations[1].id).toBe("#el2-to-1000-position"); }); it("disambiguates colliding IDs with a suffix", () => { @@ -272,8 +274,8 @@ describe("parseGsapScript", () => { `; const result = parseGsapScript(script); - expect(result.animations[0].id).toBe("#el1-to-0"); - expect(result.animations[1].id).toBe("#el1-to-0-2"); + expect(result.animations[0].id).toBe("#el1-to-0-visual"); + expect(result.animations[1].id).toBe("#el1-to-0-visual-2"); }); it("uses string position in ID for relative positions", () => { @@ -283,7 +285,219 @@ describe("parseGsapScript", () => { `; const result = parseGsapScript(script); - expect(result.animations[0].id).toBe("#el1-to-+=1"); + expect(result.animations[0].id).toBe("#el1-to-+=1-visual"); + }); +}); + +describe("resolvedStart — timeline position resolution", () => { + it("resolves chained from() tweens with relative positions (sdk-test pattern)", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out" } }); + tl.from("#headline", { duration: 0.6, scale: 0.92, transformOrigin: "left center" }) + .from("#subtext", { duration: 0.5, scale: 0.92, transformOrigin: "left center" }, "-=0.3") + .from("#box", { duration: 0.5, scale: 0.5, transformOrigin: "center center" }, "-=0.3"); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(3); + // Execution order: #headline, #subtext, #box + expect(result.animations[0].targetSelector).toBe("#headline"); + expect(result.animations[1].targetSelector).toBe("#subtext"); + expect(result.animations[2].targetSelector).toBe("#box"); + + // #headline: implicit position → starts at 0, ends at 0.6 + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[0].implicitPosition).toBe(true); + + // #subtext: "-=0.3" from cursor (0.6) → 0.6 - 0.3 = 0.3 + expect(result.animations[1].resolvedStart).toBe(0.3); + + // #box: "-=0.3" from cursor (max(0.6, 0.3+0.5=0.8) = 0.8) → 0.8 - 0.3 = 0.5 + expect(result.animations[2].resolvedStart).toBe(0.5); + }); + + it("resolves += and < positions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1"); + tl.to("#el2", { x: 100, duration: 1 }, "<"); + tl.to("#el3", { y: 50, duration: 0.3 }, "-=0.5"); + `; + const result = parseGsapScript(script); + + // #el1: "+=1" from cursor (0) → 0 + 1 = 1, ends at 1.5 + expect(result.animations[0].resolvedStart).toBe(1); + + // #el2: "<" = previous start → 1 + expect(result.animations[1].resolvedStart).toBe(1); + + // #el3: "-=0.5" from cursor (max(1.5, 1+1=2) = 2) → 2 - 0.5 = 1.5 + expect(result.animations[2].resolvedStart).toBe(1.5); + }); + + it("resolves numeric positions directly", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + tl.to("#el2", { x: 100, duration: 1 }, 2); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[1].resolvedStart).toBe(2); + }); + + it("resolves implicit sequential positions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }) + .to("#el2", { x: 100, duration: 1 }) + .to("#el3", { y: 50, duration: 0.3 }); + `; + const result = parseGsapScript(script); + + // #el1: implicit → cursor=0, ends at 0.5 + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[0].implicitPosition).toBe(true); + + // #el2: implicit → cursor=0.5, ends at 1.5 + expect(result.animations[1].resolvedStart).toBe(0.5); + expect(result.animations[1].implicitPosition).toBe(true); + + // #el3: implicit → cursor=1.5, ends at 1.8 + expect(result.animations[2].resolvedStart).toBe(1.5); + expect(result.animations[2].implicitPosition).toBe(true); + }); + + it("clamps negative resolvedStart to 0", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.2 }); + tl.to("#el2", { x: 100, duration: 1 }, "-=5"); + `; + const result = parseGsapScript(script); + + expect(result.animations[1].resolvedStart).toBe(0); + }); + + it("uses GSAP default duration (0.5) for tweens with no explicit duration", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1 }) + .to("#el2", { x: 100 }); + `; + const result = parseGsapScript(script); + + // #el1: starts at 0, duration defaults to 0.5 → cursor at 0.5 + expect(result.animations[0].resolvedStart).toBe(0); + // #el2: starts at cursor = 0.5 + expect(result.animations[1].resolvedStart).toBe(0.5); + }); + + it("treats set() as zero-duration", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.set("#el1", { opacity: 0 }); + tl.to("#el2", { opacity: 1, duration: 1 }); + `; + const result = parseGsapScript(script); + + // set() at 0, zero duration → cursor stays at 0 + expect(result.animations[0].resolvedStart).toBe(0); + // next tween starts at cursor = 0 + expect(result.animations[1].resolvedStart).toBe(0); + }); +}); + +describe("timeline defaults inheritance", () => { + it("inherits ease and duration from timeline defaults onto tweens", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } }); + tl.from("#headline", { scale: 0.92, transformOrigin: "left center" }) + .from("#subtext", { scale: 0.92 }, "-=0.3"); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].ease).toBe("power3.out"); + expect(result.animations[0].duration).toBe(0.6); + expect(result.animations[1].ease).toBe("power3.out"); + expect(result.animations[1].duration).toBe(0.6); + }); + + it("does not override explicit ease/duration on individual tweens", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } }); + tl.to("#el1", { opacity: 1, duration: 1, ease: "none" }); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].ease).toBe("none"); + expect(result.animations[0].duration).toBe(1); + }); + + it("uses inherited duration for position resolution", () => { + const script = ` + const tl = gsap.timeline({ defaults: { duration: 0.8 } }); + tl.from("#a", { scale: 0.5 }) + .from("#b", { scale: 0.5 }); + `; + const result = parseGsapScript(script); + + // #a starts at 0, duration 0.8 → cursor at 0.8 + expect(result.animations[0].resolvedStart).toBe(0); + // #b starts at cursor = 0.8 + expect(result.animations[1].resolvedStart).toBe(0.8); + }); +}); + +describe("property group classification", () => { + it("classifies individual properties into groups", () => { + expect(classifyPropertyGroup("x")).toBe("position"); + expect(classifyPropertyGroup("y")).toBe("position"); + expect(classifyPropertyGroup("xPercent")).toBe("position"); + expect(classifyPropertyGroup("scale")).toBe("scale"); + expect(classifyPropertyGroup("scaleX")).toBe("scale"); + expect(classifyPropertyGroup("width")).toBe("size"); + expect(classifyPropertyGroup("height")).toBe("size"); + expect(classifyPropertyGroup("rotation")).toBe("rotation"); + expect(classifyPropertyGroup("skewX")).toBe("rotation"); + expect(classifyPropertyGroup("opacity")).toBe("visual"); + expect(classifyPropertyGroup("autoAlpha")).toBe("visual"); + expect(classifyPropertyGroup("borderRadius")).toBe("other"); + expect(classifyPropertyGroup("fontSize")).toBe("other"); + }); + + it("classifies a pure position tween", () => { + expect(classifyTweenPropertyGroup({ x: 100, y: 50 })).toBe("position"); + }); + + it("classifies a pure scale tween", () => { + expect(classifyTweenPropertyGroup({ scale: 0.5 })).toBe("scale"); + }); + + it("classifies scale + transformOrigin as scale (transformOrigin follows group)", () => { + expect(classifyTweenPropertyGroup({ scale: 0.5, transformOrigin: "center center" })).toBe( + "scale", + ); + }); + + it("returns undefined for mixed-group tweens", () => { + expect(classifyTweenPropertyGroup({ x: 100, scale: 0.5 })).toBeUndefined(); + expect(classifyTweenPropertyGroup({ x: 100, opacity: 0 })).toBeUndefined(); + }); + + it("classifies tweens during parsing", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#a", { x: 100, y: 50, duration: 1 }, 0); + tl.to("#b", { scale: 0.5, duration: 0.5 }, 0); + tl.to("#c", { x: 100, scale: 0.5, opacity: 0, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].propertyGroup).toBe("position"); + expect(result.animations[1].propertyGroup).toBe("scale"); + expect(result.animations[2].propertyGroup).toBeUndefined(); }); }); @@ -1223,11 +1437,12 @@ describe("native GSAP keyframes parsing", () => { const kfs = expectKeyframesFormat(anim, "object-array", 3); // Total duration = 0.5 + 1 + 0.8 = 2.3 - expectKeyframe(kfs[0], 0, { x: 0, opacity: 1 }); - // Second: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22 - expectKeyframe(kfs[1], 22, { x: 100 }, "power2.out"); - // Third: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65 - expectKeyframe(kfs[2], 65, { x: 200 }); + // First: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22 + expectKeyframe(kfs[0], 22, { x: 0, opacity: 1 }); + // Second: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65 + expectKeyframe(kfs[1], 65, { x: 100 }, "power2.out"); + // Third: cumulative = 2.3, pct = round(2.3/2.3 * 100) = 100 + expectKeyframe(kfs[2], 100, { x: 200 }); }); it("parses simple array keyframes format", () => { @@ -1944,3 +2159,119 @@ tl.to("#el1", { y: 200, duration: 1 }, 3);`; expect(result.skippedSelectors).toEqual([".el1"]); }); }); + +describe("splitIntoPropertyGroups", () => { + const baseScript = `const tl = gsap.timeline({ paused: true });`; + + it("splits flat to({x, y, scale, rotation}) into 3 group tweens", () => { + const script = `${baseScript}\ntl.to("#el", { x: 100, y: 50, scale: 1.5, rotation: 45, duration: 1 }, 0);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + // Should produce 3 tweens: position (x,y), scale, rotation + expect(reParsed.animations).toHaveLength(3); + expect(result.ids).toHaveLength(3); + + const groups = new Set(reParsed.animations.map((a) => a.propertyGroup)); + expect(groups.has("position")).toBe(true); + expect(groups.has("scale")).toBe(true); + expect(groups.has("rotation")).toBe(true); + + const posAnim = reParsed.animations.find((a) => a.propertyGroup === "position")!; + expect(posAnim.properties.x).toBe(100); + expect(posAnim.properties.y).toBe(50); + expect(posAnim.properties.scale).toBeUndefined(); + + const scaleAnim = reParsed.animations.find((a) => a.propertyGroup === "scale")!; + expect(scaleAnim.properties.scale).toBe(1.5); + expect(scaleAnim.properties.x).toBeUndefined(); + + const rotAnim = reParsed.animations.find((a) => a.propertyGroup === "rotation")!; + expect(rotAnim.properties.rotation).toBe(45); + }); + + it("splits flat from({scale, opacity}) into 2 group tweens", () => { + const script = `${baseScript}\ntl.from("#el", { scale: 0.5, opacity: 0, duration: 0.5 }, 1);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + expect(reParsed.animations).toHaveLength(2); + expect(result.ids).toHaveLength(2); + + const groups = new Set(reParsed.animations.map((a) => a.propertyGroup)); + expect(groups.has("scale")).toBe(true); + expect(groups.has("visual")).toBe(true); + }); + + it("returns same ID for single-group tween (no split)", () => { + const script = `${baseScript}\ntl.to("#el", { x: 100, y: 50, duration: 1 }, 0);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + expect(result.ids).toEqual([animId]); + // Script should be unchanged + const reParsed = parseGsapScript(result.script); + expect(reParsed.animations).toHaveLength(1); + }); + + it("preserves position, duration, ease on split tweens", () => { + const script = `${baseScript}\ntl.to("#el", { x: 100, scale: 2, duration: 0.8, ease: "power2.out" }, 1.5);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + expect(reParsed.animations).toHaveLength(2); + for (const anim of reParsed.animations) { + expect(anim.position).toBe(1.5); + expect(anim.duration).toBe(0.8); + expect(anim.ease).toBe("power2.out"); + } + }); + + it("splits keyframed tween: each group gets only its properties per keyframe", () => { + const script = `${baseScript}\ntl.to("#el", { keyframes: { "0%": { x: 0, scale: 1 }, "50%": { x: 50, scale: 1.5 }, "100%": { x: 100, scale: 2 } }, duration: 2 }, 0);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + expect(reParsed.animations).toHaveLength(2); + expect(result.ids).toHaveLength(2); + + // Both tweens are keyframed — identify them by the properties inside their keyframes. + const xAnim = reParsed.animations.find((a) => + a.keyframes?.keyframes.some((kf) => "x" in kf.properties), + )!; + const scaleAnim = reParsed.animations.find((a) => + a.keyframes?.keyframes.some((kf) => "scale" in kf.properties), + )!; + + expect(xAnim).toBeDefined(); + expect(xAnim.keyframes).toBeDefined(); + expect(xAnim.keyframes!.keyframes).toHaveLength(3); + // Position keyframes should have x but not scale + for (const kf of xAnim.keyframes!.keyframes) { + expect(kf.properties.x).toBeDefined(); + expect(kf.properties.scale).toBeUndefined(); + } + + expect(scaleAnim).toBeDefined(); + expect(scaleAnim.keyframes).toBeDefined(); + expect(scaleAnim.keyframes!.keyframes).toHaveLength(3); + // Scale keyframes should have scale but not x + for (const kf of scaleAnim.keyframes!.keyframes) { + expect(kf.properties.scale).toBeDefined(); + expect(kf.properties.x).toBeUndefined(); + } + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index e9f6ea6d8..eaf962675 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -39,6 +39,14 @@ export { SUPPORTED_PROPS, SUPPORTED_EASES, } from "./gsapSerialize"; +export type { PropertyGroupName } from "./gsapConstants"; +export { + PROPERTY_GROUPS, + classifyPropertyGroup, + classifyTweenPropertyGroup, +} from "./gsapConstants"; +import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants"; +import type { PropertyGroupName } from "./gsapConstants"; export { generateSpringEaseData, SPRING_PRESETS } from "./springEase"; export type { SpringPreset } from "./springEase"; @@ -343,19 +351,47 @@ function isGsapTimelineCall(node: any): boolean { ); } +interface TimelineDefaults { + ease?: string; + duration?: number; +} + interface TimelineDetection { timelineVar: string | null; timelineCount: number; + defaults?: TimelineDefaults; } -function findTimelineVar(ast: any): TimelineDetection { +function extractTimelineDefaults( + callNode: any, + scope: ScopeBindings, +): TimelineDefaults | undefined { + const arg = callNode.arguments?.[0]; + if (!arg || arg.type !== "ObjectExpression") return undefined; + const defaultsProp = arg.properties?.find( + (p: any) => isObjectProperty(p) && propKeyName(p) === "defaults", + ); + if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression") return undefined; + const record = objectExpressionToRecord(defaultsProp.value, scope); + const result: TimelineDefaults = {}; + if (typeof record.ease === "string") result.ease = record.ease; + if (typeof record.duration === "number") result.duration = record.duration; + return Object.keys(result).length > 0 ? result : undefined; +} + +function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection { let timelineVar: string | null = null; let timelineCount = 0; + let defaults: TimelineDefaults | undefined; + const emptyScope: ScopeBindings = scope ?? new Map(); recast.types.visit(ast, { visitVariableDeclarator(path: any) { if (isGsapTimelineCall(path.node.init)) { timelineCount += 1; - if (!timelineVar) timelineVar = path.node.id?.name ?? null; + if (!timelineVar) { + timelineVar = path.node.id?.name ?? null; + defaults = extractTimelineDefaults(path.node.init, emptyScope); + } } this.traverse(path); }, @@ -365,12 +401,13 @@ function findTimelineVar(ast: any): TimelineDetection { if (!timelineVar) { const left = path.node.left; if (left?.type === "Identifier") timelineVar = left.name; + defaults = extractTimelineDefaults(path.node.right, emptyScope); } } this.traverse(path); }, }); - return { timelineVar, timelineCount }; + return { timelineVar, timelineCount, defaults }; } // ── Find All Tween Calls ──────────────────────────────────────────────────── @@ -632,13 +669,13 @@ function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyfram if (totalDuration > 0) { let cumulative = 0; for (const entry of raw) { + cumulative += entry.duration ?? 0; const percentage = Math.round((cumulative / totalDuration) * 100); keyframes.push({ percentage, properties: entry.properties, ...(entry.ease ? { ease: entry.ease } : {}), }); - cumulative += entry.duration ?? 0; } } else { for (let i = 0; i < raw.length; i++) { @@ -872,7 +909,8 @@ function tweenCallToAnimation( } } - const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0; + const hasPositionArg = !!call.positionArg; + const posVal = hasPositionArg ? extractLiteralValue(call.positionArg, scope) : 0; const position: number | string = typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0; let duration = typeof vars.duration === "number" ? vars.duration : undefined; @@ -891,6 +929,16 @@ function tweenCallToAnimation( duration, ease, }; + if (!hasPositionArg) anim.implicitPosition = true; + let group = classifyTweenPropertyGroup(properties); + if (!group && keyframesData) { + const kfProps: Record = {}; + for (const kf of keyframesData.keyframes) { + for (const k of Object.keys(kf.properties)) kfProps[k] = true; + } + group = classifyTweenPropertyGroup(kfProps); + } + if (group) anim.propertyGroup = group; if (Object.keys(extras).length > 0) anim.extras = extras; if (keyframesData) anim.keyframes = keyframesData; if (motionPathResult) anim.arcPath = motionPathResult.arcPath; @@ -899,8 +947,96 @@ function tweenCallToAnimation( return anim; } +// ── Timeline Position Resolution ────────────────────────────────────────── + +const GSAP_DEFAULT_DURATION = 0.5; + +// NOTE: Label-based positions (e.g. "myLabel+=0.5") are not yet resolved — +// they fall through to parseFloat which returns null for non-numeric strings. +function resolvePositionString(pos: string, cursor: number, prevStart: number): number | null { + const trimmed = pos.trim(); + if (trimmed === "") return cursor; + if (trimmed.startsWith("+=")) { + const n = Number.parseFloat(trimmed.slice(2)); + return Number.isFinite(n) ? cursor + n : null; + } + if (trimmed.startsWith("-=")) { + const n = Number.parseFloat(trimmed.slice(2)); + return Number.isFinite(n) ? cursor - n : null; + } + if (trimmed === "<") return prevStart; + if (trimmed === ">") return cursor; + if (trimmed.startsWith("<")) { + const n = Number.parseFloat(trimmed.slice(1)); + return Number.isFinite(n) ? prevStart + n : null; + } + if (trimmed.startsWith(">")) { + const n = Number.parseFloat(trimmed.slice(1)); + return Number.isFinite(n) ? cursor + n : null; + } + const n = Number.parseFloat(trimmed); + return Number.isFinite(n) ? n : null; +} + +function applyTimelineDefaults( + anims: Omit[], + defaults?: TimelineDefaults, +): void { + if (!defaults) return; + for (const anim of anims) { + if (anim.method === "set") continue; + if (anim.duration === undefined && defaults.duration !== undefined) { + anim.duration = defaults.duration; + } + if (anim.ease === undefined && defaults.ease !== undefined) { + anim.ease = defaults.ease; + } + } +} + +function resolveTimelinePositions(anims: Omit[]): void { + let cursor = 0; + let prevStart = 0; + for (const anim of anims) { + const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION); + let start: number | null; + + if (anim.implicitPosition) { + start = cursor; + } else if (typeof anim.position === "number") { + start = anim.position; + } else if (typeof anim.position === "string") { + start = resolvePositionString(anim.position, cursor, prevStart); + } else { + start = cursor; + } + + if (start != null) { + anim.resolvedStart = Math.max(0, start); + prevStart = anim.resolvedStart; + cursor = Math.max(cursor, anim.resolvedStart + duration); + } + } +} + +function sortBySourcePosition(calls: TweenCallInfo[]): void { + calls.sort((a, b) => { + const aLoc = a.node.callee?.property?.loc?.start; + const bLoc = b.node.callee?.property?.loc?.start; + if (!aLoc || !bLoc) return 0; + return aLoc.line - bLoc.line || aLoc.column - bLoc.column; + }); +} + // ── Stable ID Generation ─────────────────────────────────────────────────── +/** + * IDs are transient — recomputed on every parse, never persisted across sessions. + * They exist only in ephemeral request/response payloads, React component state, + * and the in-memory keyframe cache (rebuilt on every page load). No database, + * localStorage, or file stores animation IDs, so changing the ID format (e.g. + * adding a `-scale`/`-position` suffix) is safe. + */ function assignStableIds(anims: Omit[]): GsapAnimation[] { const counts = new Map(); return anims.map((anim) => { @@ -908,7 +1044,8 @@ function assignStableIds(anims: Omit[]): GsapAnimation[] { typeof anim.position === "number" ? String(Math.round(anim.position * 1000)) : String(anim.position); - const base = `${anim.targetSelector}-${anim.method}-${posKey}`; + const groupSuffix = anim.propertyGroup ? `-${anim.propertyGroup}` : ""; + const base = `${anim.targetSelector}-${anim.method}-${posKey}${groupSuffix}`; const count = (counts.get(base) ?? 0) + 1; counts.set(base, count); const id = count === 1 ? base : `${base}-${count}`; @@ -937,10 +1074,14 @@ function parseGsapAst(script: string): ParsedGsapAst { const ast = parseScript(script); const scope = collectScopeBindings(ast); const targetBindings = collectTargetBindings(ast, scope); - const detection = findTimelineVar(ast); + const detection = findTimelineVar(ast, scope); const timelineVar = detection.timelineVar ?? "tl"; const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); - const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope))); + sortBySourcePosition(calls); + const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope)); + applyTimelineDefaults(rawAnims, detection.defaults); + resolveTimelinePositions(rawAnims); + const animations = assignStableIds(rawAnims); const located = animations.map((animation, i) => ({ id: animation.id, call: calls[i]!, @@ -1300,7 +1441,11 @@ export function removeAnimationFromScript(script: string, animationId: string): console.warn("[gsap-parser] removeAnimationFromScript parse failed:", e); return script; } - const target = parsed.located.find((l) => l.id === animationId); + let target = parsed.located.find((l) => l.id === animationId); + if (!target) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + target = parsed.located.find((l) => l.id === convertedId); + } if (!target) return script; const node = target.call.node; const stmtPath = findStatementPath(target.call.path); @@ -1514,6 +1659,21 @@ function percentageFromKey(key: string): number { return m ? Number.parseFloat(m[1]!) : Number.NaN; } +const PCT_TOLERANCE = 2; + +function findKeyframePropByPct(kfNode: any, percentage: number): { idx: number; prop: any } | null { + const props = kfNode.properties; + for (let i = 0; i < props.length; i++) { + if (!isObjectProperty(props[i])) continue; + const key = propKeyName(props[i]); + if (typeof key !== "string") continue; + const parsed = percentageFromKey(key); + if (Number.isNaN(parsed)) continue; + if (Math.abs(parsed - percentage) <= PCT_TOLERANCE) return { idx: i, prop: props[i] }; + } + return null; +} + /** Build a keyframe value AST node from properties and optional ease. */ function buildKeyframeValueNode(properties: Record, ease?: string): any { const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); @@ -1571,7 +1731,11 @@ function collapseKeyframesToFlat(varsArg: any, record: Record): * updateKeyframeInScript. */ function locateKeyframeCtx(script: string, animationId: string, percentage: number) { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return null; const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); if (!kfNode) return null; @@ -1613,12 +1777,20 @@ export function addKeyframeToScript( const newValueNode = buildKeyframeValueNode(properties, ease); - // Replace if this percentage already exists - const existingIdx = kfNode.properties.findIndex( - (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, - ); - if (existingIdx !== -1) { - kfNode.properties[existingIdx].value = newValueNode; + // Merge into existing keyframe at this percentage, or insert new + const existing = findKeyframePropByPct(kfNode, percentage); + if (existing) { + if (existing.prop.value?.type === "ObjectExpression") { + const existingRecord = objectExpressionToRecord(existing.prop.value, loc.parsed.scope); + const merged = { ...existingRecord }; + for (const [k, v] of Object.entries(properties)) merged[k] = v; + existing.prop.value = buildKeyframeValueNode( + merged as Record, + ease ?? (typeof existingRecord.ease === "string" ? existingRecord.ease : undefined), + ); + } else { + existing.prop.value = newValueNode; + } } else { // Build the new property node with a quoted percentage key const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0]; @@ -1703,12 +1875,11 @@ export function removeKeyframeFromScript( ): string { const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; - const { loc, kfNode, pctKey } = ctx; + const { loc, kfNode } = ctx; - const removeIdx = kfNode.properties.findIndex( - (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, - ); - if (removeIdx === -1) return script; + const match = findKeyframePropByPct(kfNode, percentage); + if (!match) return script; + const removeIdx = match.idx; kfNode.properties.splice(removeIdx, 1); @@ -1736,14 +1907,12 @@ export function updateKeyframeInScript( ): string { const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; - const { loc, kfNode, pctKey } = ctx; + const { loc, kfNode } = ctx; - const existing = kfNode.properties.find( - (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, - ); - if (!existing) return script; + const match = findKeyframePropByPct(kfNode, percentage); + if (!match) return script; - existing.value = buildKeyframeValueNode(properties, ease); + match.prop.value = buildKeyframeValueNode(properties, ease); return recast.print(loc.parsed.ast).code; } @@ -1760,32 +1929,47 @@ function cssIdentityValue(prop: string): number { return CSS_IDENTITY[prop] ?? 0; } +/** + * Resolve the 0% (from) and 100% (to) property maps for a tween being + * converted to percentage keyframes. + * + * @param resolvedFromValues — Despite the "from" in the name (historical), these + * are runtime-captured DOM values that override the conversion endpoint: + * - For to(): overrides fromProps (the 0% state / where the element is now). + * - For from(): overrides toProps (the 100% state / where the element rests). + * - For fromTo(): merges into toProps (the 100% endpoint the user is editing). + */ function resolveConversionProps( anim: GsapAnimation, resolvedFromValues?: Record, ): { fromProps: Record; toProps: Record } { if (anim.method === "to") { - if (resolvedFromValues) { - return { fromProps: resolvedFromValues, toProps: { ...anim.properties } }; - } const identityFrom: Record = {}; for (const [key, val] of Object.entries(anim.properties)) { if (val != null) identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val; } - return { fromProps: identityFrom, toProps: { ...anim.properties } }; + const fromProps = resolvedFromValues + ? { ...identityFrom, ...resolvedFromValues } + : identityFrom; + return { fromProps, toProps: { ...anim.properties } }; } if (anim.method === "from") { - if (resolvedFromValues) { - return { fromProps: { ...anim.properties }, toProps: resolvedFromValues }; - } const identityTo: Record = {}; for (const [key, val] of Object.entries(anim.properties)) { if (val != null) identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val; } - return { fromProps: { ...anim.properties }, toProps: identityTo }; + const toProps = resolvedFromValues ? { ...identityTo, ...resolvedFromValues } : identityTo; + return { fromProps: { ...anim.properties }, toProps }; } - // fromTo - return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps: { ...anim.properties } }; + // fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state), + // anim.properties = toVars (100% state). resolvedFromValues contains the + // current DOM position from a drag — it represents the NEW destination, so + // it merges into toProps (the 100% endpoint the user is editing), NOT into + // fromProps. This is intentional and not inverted. + const toProps = resolvedFromValues + ? { ...anim.properties, ...resolvedFromValues } + : { ...anim.properties }; + return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps }; } /** Strip editable properties and ease/keyframes keys from a varsArg. */ @@ -1827,7 +2011,11 @@ export function convertToKeyframesInScript( animationId: string, resolvedFromValues?: Record, ): string { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return script; const anim = loc.target.animation; @@ -1858,7 +2046,11 @@ export function convertToKeyframesInScript( * last keyframe's properties. */ export function removeAllKeyframesFromScript(script: string, animationId: string): string { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return script; const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); if (!kfNode) return script; @@ -1894,7 +2086,11 @@ export function materializeKeyframesInScript( easeEach?: string, resolvedSelector?: string, ): string { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return script; const varsArg = loc.target.call.varsArg; @@ -2143,6 +2339,156 @@ export function removeArcPathFromScript(script: string, animationId: string): st }); } +// ── Split Into Property Groups ──────────────────────────────────────────── + +/** + * Split a multi-group tween into separate per-group tweens. Each resulting + * tween contains only properties belonging to one property group (position, + * scale, rotation, visual, etc.). `transformOrigin` stays with the group that + * has the most properties. If the tween already belongs to a single group, + * returns the script unchanged with the original ID. + */ +// fallow-ignore-next-line complexity +export function splitIntoPropertyGroups( + script: string, + animationId: string, +): { script: string; ids: string[] } { + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } + if (!loc) return { script, ids: [animationId] }; + + const anim = loc.target.animation; + + // Collect the properties to partition. For keyframed tweens, gather the + // union of all properties across all keyframes. For flat tweens, use the + // tween's own properties map. + const allPropKeys = new Set(); + if (anim.keyframes) { + for (const kf of anim.keyframes.keyframes) { + for (const k of Object.keys(kf.properties)) allPropKeys.add(k); + } + } else { + for (const k of Object.keys(anim.properties)) allPropKeys.add(k); + } + + // Partition properties into groups (excluding transformOrigin — handled below). + const groupProps = new Map(); + for (const key of allPropKeys) { + if (key === "transformOrigin") continue; + const group = classifyPropertyGroup(key); + let arr = groupProps.get(group); + if (!arr) { + arr = []; + groupProps.set(group, arr); + } + arr.push(key); + } + + // Only one group (or zero) — no split needed. + if (groupProps.size <= 1) return { script, ids: [anim.id] }; + + // Assign transformOrigin to the group with the most properties. + if (allPropKeys.has("transformOrigin")) { + let largestGroup: PropertyGroupName | undefined; + let largestCount = 0; + for (const [group, props] of groupProps) { + if (props.length > largestCount) { + largestCount = props.length; + largestGroup = group; + } + } + if (largestGroup) { + groupProps.get(largestGroup)!.push("transformOrigin"); + } + } + + // Build per-group tweens and insert them, then remove the original. + let result = script; + + // Remove the original tween first. + result = removeAnimationFromScript(result, anim.id); + + // Insert one tween per group. Iteration order of the Map follows insertion + // order, which mirrors the order properties were encountered. + for (const [, props] of groupProps) { + const propSet = new Set(props); + + if (anim.keyframes) { + // Build keyframes containing only this group's properties per keyframe. + const groupKeyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + auto?: boolean; + }> = []; + + for (const kf of anim.keyframes.keyframes) { + const filtered: Record = {}; + for (const [k, v] of Object.entries(kf.properties)) { + if (propSet.has(k)) filtered[k] = v; + } + // Skip keyframes where this group has zero properties. + if (Object.keys(filtered).length === 0) continue; + groupKeyframes.push({ + percentage: kf.percentage, + properties: filtered, + ...(kf.ease ? { ease: kf.ease } : {}), + }); + } + + if (groupKeyframes.length === 0) continue; + + const addResult = addAnimationWithKeyframesToScript( + result, + anim.targetSelector, + typeof anim.position === "number" ? anim.position : 0, + anim.duration ?? 0.5, + groupKeyframes, + anim.keyframes.easeEach ?? anim.ease, + ); + result = addResult.script; + } else { + // Flat tween — filter properties to this group. + const groupProperties: Record = {}; + for (const [k, v] of Object.entries(anim.properties)) { + if (propSet.has(k)) groupProperties[k] = v; + } + if (Object.keys(groupProperties).length === 0) continue; + + let fromProperties: Record | undefined; + if (anim.method === "fromTo" && anim.fromProperties) { + fromProperties = {}; + for (const [k, v] of Object.entries(anim.fromProperties)) { + if (propSet.has(k)) fromProperties[k] = v; + } + } + + const addResult = addAnimationToScript(result, { + targetSelector: anim.targetSelector, + method: anim.method, + position: anim.position, + duration: anim.duration, + ease: anim.ease, + properties: groupProperties, + fromProperties, + extras: anim.extras, + }); + result = addResult.script; + } + } + + // Re-parse to collect the new IDs. + const reParsed = parseGsapAst(result); + const newIds = reParsed.located + .filter((l) => l.animation.targetSelector === anim.targetSelector) + .map((l) => l.id); + + return { script: result, ids: newIds }; +} + /** * Replace a dynamic loop that generates multiple tween calls with individual * static `tl.to()` calls — one per element. Finds the loop containing the @@ -2213,7 +2559,7 @@ export function unrollDynamicAnimations( kfEntries.push(`easeEach: ${JSON.stringify(el.easeEach)}`); } calls.push( - `tl.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`, + `${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`, ); } diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index b480190e6..fc5a6220a 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -7,6 +7,7 @@ * parsing of GSAP source lives in the Node-only `./gsapParser` module. */ import type { Keyframe, KeyframeProperties, ValidationResult } from "../core.types"; +import type { PropertyGroupName } from "./gsapConstants"; export type GsapMethod = "set" | "to" | "from" | "fromTo"; @@ -29,6 +30,13 @@ export interface GsapAnimation { hasUnresolvedKeyframes?: boolean; /** True when the tween's target selector couldn't be statically resolved (dynamic). */ hasUnresolvedSelector?: boolean; + /** Absolute start time computed by walking the timeline chain (handles +=, -=, <, >, labels). */ + resolvedStart?: number; + /** True when no position arg was authored — the tween is sequentially placed by GSAP. */ + implicitPosition?: boolean; + /** Which property group this tween belongs to (position, scale, size, rotation, visual, other). + * Undefined for legacy mixed tweens that bundle multiple groups. */ + propertyGroup?: PropertyGroupName; } export interface GsapPercentageKeyframe { @@ -77,8 +85,10 @@ export function serializeGsapAnimations( options?: { includeMediaSync?: boolean; preamble?: string; postamble?: string }, ): string { const sorted = [...animations].sort((a, b) => { - const aNum = typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER; - const bNum = typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER; + const aNum = + a.resolvedStart ?? (typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER); + const bNum = + b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER); return aNum - bNum; }); const lines = sorted.map((anim) => {