mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): per-property-group keyframe foundations (#1354)
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.
This commit is contained in:
@@ -1,41 +1,5 @@
|
||||
{
|
||||
"animations": [
|
||||
{
|
||||
"targetSelector": ".ambient-line",
|
||||
"method": "from",
|
||||
"position": 0.16,
|
||||
"properties": {
|
||||
"scaleX": 0,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.42,
|
||||
"extras": {
|
||||
"stagger": "__raw:0.08"
|
||||
},
|
||||
"id": ".ambient-line-from-160"
|
||||
},
|
||||
{
|
||||
"targetSelector": ".ambient-word",
|
||||
"method": "from",
|
||||
"position": 0.08,
|
||||
"properties": {
|
||||
"scale": 0.92,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.5,
|
||||
"id": ".ambient-word-from-80"
|
||||
},
|
||||
{
|
||||
"targetSelector": ".headline .sub",
|
||||
"method": "from",
|
||||
"position": 0.2,
|
||||
"properties": {
|
||||
"y": 20,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.28,
|
||||
"id": ".headline .sub-from-200"
|
||||
},
|
||||
{
|
||||
"targetSelector": ".headline span",
|
||||
"method": "from",
|
||||
@@ -49,7 +13,50 @@
|
||||
"extras": {
|
||||
"stagger": "__raw:0.055"
|
||||
},
|
||||
"resolvedStart": 0.05,
|
||||
"id": ".headline span-from-50"
|
||||
},
|
||||
{
|
||||
"targetSelector": ".headline .sub",
|
||||
"method": "from",
|
||||
"position": 0.2,
|
||||
"properties": {
|
||||
"y": 20,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.28,
|
||||
"ease": "power3.out",
|
||||
"resolvedStart": 0.2,
|
||||
"id": ".headline .sub-from-200"
|
||||
},
|
||||
{
|
||||
"targetSelector": ".ambient-word",
|
||||
"method": "from",
|
||||
"position": 0.08,
|
||||
"properties": {
|
||||
"scale": 0.92,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.5,
|
||||
"ease": "power3.out",
|
||||
"resolvedStart": 0.08,
|
||||
"id": ".ambient-word-from-80"
|
||||
},
|
||||
{
|
||||
"targetSelector": ".ambient-line",
|
||||
"method": "from",
|
||||
"position": 0.16,
|
||||
"properties": {
|
||||
"scaleX": 0,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.42,
|
||||
"ease": "power3.out",
|
||||
"extras": {
|
||||
"stagger": "__raw:0.08"
|
||||
},
|
||||
"resolvedStart": 0.16,
|
||||
"id": ".ambient-line-from-160"
|
||||
}
|
||||
],
|
||||
"timelineVar": "tl",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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<PropertyGroupName, ReadonlySet<string>> = {
|
||||
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<string>(),
|
||||
};
|
||||
|
||||
const PROP_TO_GROUP = new Map<string, PropertyGroupName>();
|
||||
for (const [group, props] of Object.entries(PROPERTY_GROUPS) as [
|
||||
PropertyGroupName,
|
||||
ReadonlySet<string>,
|
||||
][]) {
|
||||
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<string, unknown>,
|
||||
): PropertyGroupName | undefined {
|
||||
const groups = new Set<PropertyGroupName>();
|
||||
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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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<GsapAnimation, "id">[],
|
||||
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<GsapAnimation, "id">[]): 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, "id">[]): GsapAnimation[] {
|
||||
const counts = new Map<string, number>();
|
||||
return anims.map((anim) => {
|
||||
@@ -908,7 +1044,8 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): 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<string, number | string>, 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<string, unknown>):
|
||||
* 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<string, number | string>,
|
||||
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<string, number | string>,
|
||||
): { fromProps: Record<string, number | string>; toProps: Record<string, number | string> } {
|
||||
if (anim.method === "to") {
|
||||
if (resolvedFromValues) {
|
||||
return { fromProps: resolvedFromValues, toProps: { ...anim.properties } };
|
||||
}
|
||||
const identityFrom: Record<string, number | string> = {};
|
||||
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<string, number | string> = {};
|
||||
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, number | 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 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<string>();
|
||||
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<PropertyGroupName, string[]>();
|
||||
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<string, number | string>;
|
||||
ease?: string;
|
||||
auto?: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const kf of anim.keyframes.keyframes) {
|
||||
const filtered: Record<string, number | string> = {};
|
||||
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<string, number | string> = {};
|
||||
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<string, number | string> | 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});`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user