feat(sdk,core): ws-3 — materializeKeyframes + splitIntoPropertyGroups acorn ports + SDK ops

- acorn: buildKeyframeObjectCode, materializeKeyframesFromScript, addAnimationWithKeyframesToScript
- acorn: splitIntoPropertyGroupsFromScript with filterGroupKeyframes/filterGroupProperties helpers
- parity tests: materialize (2 positive + 1 no-op) and split (2 positive + 2 no-op) suites
- SDK types: materializeKeyframes + splitIntoPropertyGroups EditOp variants
- mutate.ts: handlers + can() gates for both new ops
- mutate.gsap.test.ts: 6 new tests (53 total passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-17 10:20:56 -07:00
co-authored by Claude Sonnet 4.6 Miguel Ángel
parent 761221e583
commit dc60436505
5 changed files with 530 additions and 1 deletions
@@ -12,11 +12,15 @@ import {
parseGsapScript,
removeAllKeyframesFromScript as removeAllRecast,
convertToKeyframesInScript as convertRecast,
materializeKeyframesInScript as materializeRecast,
splitIntoPropertyGroups as splitGroupsRecast,
} from "./gsapParser.js";
import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js";
import {
removeAllKeyframesFromScript as removeAllAcorn,
convertToKeyframesFromScript as convertAcorn,
materializeKeyframesFromScript as materializeAcorn,
splitIntoPropertyGroupsFromScript as splitGroupsAcorn,
} from "./gsapWriterAcorn.js";
function acornId(script: string): string {
@@ -176,3 +180,124 @@ describe("parity: convertToKeyframesFromScript (recast vs acorn)", () => {
expect(convertAcorn(script, id)).toBe(script);
});
});
// ── materializeKeyframes parity ───────────────────────────────────────────────
const MATERIALIZE_KFS = [
{ percentage: 0, properties: { x: 0, opacity: 1 } },
{ percentage: 50, properties: { x: 150, opacity: 0.5 } },
{ percentage: 100, properties: { x: 300, opacity: 0 } },
];
const MATERIALIZE_FIXTURES: Array<{
name: string;
script: string;
kfs: typeof MATERIALIZE_KFS;
easeEach?: string;
}> = [
{
name: "flat tween — adds keyframes property",
script: `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { x: 300, duration: 2 }, 0);
`,
kfs: MATERIALIZE_KFS,
},
{
name: "with easeEach",
script: `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { opacity: 0, duration: 1 }, 0);
`,
kfs: [
{ percentage: 0, properties: { opacity: 1 } },
{ percentage: 100, properties: { opacity: 0 } },
],
easeEach: "power2.inOut",
},
];
describe("parity: materializeKeyframesFromScript (recast vs acorn)", () => {
for (const { name, script, kfs, easeEach } of MATERIALIZE_FIXTURES) {
it(name, () => {
const id = acornId(script);
const recastOut = materializeRecast(script, id, kfs, easeEach);
const acornOut = materializeAcorn(script, id, kfs, easeEach);
const recastShape = shapeOf(recastOut);
const acornShape = shapeOf(acornOut);
expect(acornShape.keyframes).toBeDefined();
expect(acornShape).toEqual(recastShape);
});
}
it("no-op when id not found", () => {
const script = MATERIALIZE_FIXTURES[0]!.script;
expect(materializeAcorn(script, "nope", MATERIALIZE_KFS)).toBe(script);
});
});
// ── splitIntoPropertyGroups parity ────────────────────────────────────────────
function shapesOf(script: string) {
return parseGsapScript(script).animations.map((a) => ({
method: a.method,
properties: a.properties,
keyframes: a.keyframes,
duration: a.duration,
ease: a.ease,
selector: a.targetSelector,
propertyGroup: a.propertyGroup,
}));
}
const SPLIT_FIXTURES: Array<{ name: string; script: string }> = [
{
name: "flat mixed tween — splits into position + visual groups",
script: `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { x: 100, y: 50, opacity: 0.5, duration: 1 }, 0);
`,
},
{
name: "keyframed mixed tween — splits per group",
script: `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { x: 0, opacity: 1 }, "100%": { x: 200, opacity: 0 } }, duration: 1 }, 0);
`,
},
];
describe("parity: splitIntoPropertyGroupsFromScript (recast vs acorn)", () => {
for (const { name, script } of SPLIT_FIXTURES) {
it(name, () => {
const id = acornId(script);
const { script: recastOut } = splitGroupsRecast(script, id);
const { script: acornOut } = splitGroupsAcorn(script, id);
const recastShapes = shapesOf(recastOut);
const acornShapes = shapesOf(acornOut);
expect(acornShapes).toHaveLength(recastShapes.length);
expect(acornShapes.length).toBeGreaterThan(1);
// Each produced group should match its counterpart by propertyGroup
const sortByGroup = (arr: typeof recastShapes) =>
arr.slice().sort((a, b) => (a.propertyGroup ?? "").localeCompare(b.propertyGroup ?? ""));
expect(sortByGroup(acornShapes)).toEqual(sortByGroup(recastShapes));
});
}
it("no-op when id not found", () => {
const script = SPLIT_FIXTURES[0]!.script;
const { script: out, ids } = splitGroupsAcorn(script, "nope");
expect(out).toBe(script);
expect(ids).toEqual(["nope"]);
});
it("no-op when single-group tween", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, y: 50, duration: 1 }, 0);
`;
const id = acornId(script);
const { script: out } = splitGroupsAcorn(script, id);
expect(out).toBe(script);
});
});
+259 -1
View File
@@ -7,13 +7,15 @@
* pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts.
*/
import MagicString from "magic-string";
import type { GsapAnimation } from "./gsapSerialize.js";
import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapSerialize.js";
import { resolveConversionProps } from "./gsapSerialize.js";
import {
parseGsapScriptAcornForWrite,
type ParsedGsapAcornForWrite,
type TweenCallInfo,
} from "./gsapParserAcorn.js";
import { classifyPropertyGroup } from "./gsapConstants.js";
import type { PropertyGroupName } from "./gsapConstants.js";
import * as acornWalk from "acorn-walk";
// ── Code generation helpers ──────────────────────────────────────────────────
@@ -997,6 +999,262 @@ export function convertToKeyframesFromScript(
return ms.toString();
}
// ── Keyframe-object code builder ─────────────────────────────────────────────
/** Build a percentage-keyframes object literal: `{ "0%": { x: 0 }, "100%": { x: 100 } }`. */
function buildKeyframeObjectCode(
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>,
easeEach?: string,
): string {
const entries = keyframes.map((kf) => {
const props = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`;
});
if (easeEach) entries.push(`easeEach: ${JSON.stringify(easeEach)}`);
return `{ ${entries.join(", ")} }`;
}
// ── Materialize keyframes ────────────────────────────────────────────────────
/**
* Replace a dynamic or static keyframes expression with a fully-resolved
* percentage-keyframes object. Called when a user first edits a dynamically-
* generated keyframe in the studio so it becomes statically editable.
*/
export function materializeKeyframesFromScript(
script: string,
animationId: string,
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>,
easeEach?: string,
resolvedSelector?: string,
): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const { call } = target;
const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
const kfObjCode = buildKeyframeObjectCode(sorted, easeEach);
const ms = new MagicString(script);
if (resolvedSelector) {
const selectorArg = call.node.arguments[0];
if (selectorArg)
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(resolvedSelector));
}
const kfProp = findPropertyNode(call.varsArg, "keyframes");
if (kfProp) {
ms.overwrite(kfProp.value.start, kfProp.value.end, kfObjCode);
} else if (call.varsArg?.type === "ObjectExpression") {
const vars = call.varsArg;
if (vars.properties.length > 0) {
ms.prependLeft(vars.properties[0].start, `keyframes: ${kfObjCode}, `);
} else {
ms.appendLeft(vars.end - 1, `keyframes: ${kfObjCode}`);
}
}
const eachProp = findPropertyNode(call.varsArg, "easeEach");
if (eachProp) {
const allProps = (call.varsArg.properties ?? []).filter((p: any) => isObjectProperty(p));
removeProp(ms, eachProp, allProps);
}
return ms.toString();
}
// ── Add animation with keyframes ──────────────────────────────────────────────
/** Insert a new keyframed `to()` call and return the new animation ID. */
export function addAnimationWithKeyframesToScript(
script: string,
targetSelector: string,
position: number,
duration: number,
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>,
ease?: string,
): { script: string; id: string } {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return { script, id: "" };
const insertionPoint = findInsertionPoint(parsed);
if (insertionPoint === null) return { script, id: "" };
const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
const kfObjCode = buildKeyframeObjectCode(sorted);
const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`];
if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`);
const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`;
const ms = new MagicString(script);
ms.appendLeft(insertionPoint, "\n" + stmtCode);
const result = ms.toString();
const reParsed = parseGsapScriptAcornForWrite(result);
const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? "";
return { script: result, id: newId };
}
// ── Split into property groups ────────────────────────────────────────────────
function collectPropertyKeys(anim: GsapAnimation): Set<string> {
const keys = new Set<string>();
if (anim.keyframes) {
for (const kf of anim.keyframes.keyframes) {
for (const k of Object.keys(kf.properties)) keys.add(k);
}
} else {
for (const k of Object.keys(anim.properties)) keys.add(k);
}
return keys;
}
function partitionPropertyGroups(keys: Set<string>): Map<PropertyGroupName, string[]> {
const groups = new Map<PropertyGroupName, string[]>();
for (const key of keys) {
if (key === "transformOrigin") continue;
const group = classifyPropertyGroup(key);
let arr = groups.get(group);
if (!arr) {
arr = [];
groups.set(group, arr);
}
arr.push(key);
}
return groups;
}
function assignTransformOrigin(groupProps: Map<PropertyGroupName, string[]>): void {
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");
}
function filterGroupKeyframes(
kfs: GsapPercentageKeyframe[],
propSet: Set<string>,
): Array<{ percentage: number; properties: Record<string, number | string>; ease?: string }> {
const result: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}> = [];
for (const kf of kfs) {
const filtered: Record<string, number | string> = {};
for (const [k, v] of Object.entries(kf.properties)) {
if (propSet.has(k)) filtered[k] = v;
}
if (Object.keys(filtered).length > 0) {
result.push({
percentage: kf.percentage,
properties: filtered,
...(kf.ease ? { ease: kf.ease } : {}),
});
}
}
return result;
}
function filterGroupProperties(
properties: Record<string, number | string>,
propSet: Set<string>,
): Record<string, number | string> {
const result: Record<string, number | string> = {};
for (const [k, v] of Object.entries(properties)) {
if (propSet.has(k)) result[k] = v;
}
return result;
}
function addGroupAnimToScript(
script: string,
anim: GsapAnimation,
propSet: Set<string>,
): { script: string; id: string } {
if (anim.keyframes) {
const groupKeyframes = filterGroupKeyframes(anim.keyframes.keyframes, propSet);
if (groupKeyframes.length === 0) return { script, id: "" };
const pos = typeof anim.position === "number" ? anim.position : 0;
return addAnimationWithKeyframesToScript(
script,
anim.targetSelector,
pos,
anim.duration ?? 0.5,
groupKeyframes,
anim.keyframes.easeEach ?? anim.ease,
);
}
const groupProperties = filterGroupProperties(anim.properties, propSet);
if (Object.keys(groupProperties).length === 0) return { script, id: "" };
const fromProperties =
anim.method === "fromTo" && anim.fromProperties
? filterGroupProperties(anim.fromProperties, propSet)
: undefined;
return addAnimationToScript(script, {
targetSelector: anim.targetSelector,
method: anim.method,
position: anim.position,
duration: anim.duration,
ease: anim.ease,
properties: groupProperties,
fromProperties,
extras: anim.extras,
});
}
/**
* Split a mixed-property tween into one tween per property group (position,
* scale, visual, etc.) so each group can be edited independently.
* Returns the updated script and the IDs of the newly-created tweens.
*/
export function splitIntoPropertyGroupsFromScript(
script: string,
animationId: string,
): { script: string; ids: string[] } {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return { script, ids: [animationId] };
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return { script, ids: [animationId] };
const { animation } = target;
const allPropKeys = collectPropertyKeys(animation);
const groupProps = partitionPropertyGroups(allPropKeys);
if (groupProps.size <= 1) return { script, ids: [animationId] };
if (allPropKeys.has("transformOrigin")) assignTransformOrigin(groupProps);
let result = removeAnimationFromScript(script, animationId);
for (const [, props] of groupProps) {
const { script: next, id } = addGroupAnimToScript(result, animation, new Set(props));
if (id) result = next;
}
const reParsed = parseGsapScriptAcornForWrite(result);
const newIds = (reParsed?.located ?? [])
.filter((l) => l.animation.targetSelector === animation.targetSelector)
.map((l) => l.id);
return { script: result, ids: newIds };
}
// ── Label write ops ───────────────────────────────────────────────────────────
export function addLabelToScript(script: string, name: string, position: number): string {
@@ -583,6 +583,89 @@ window.__timelines["t"] = tl;`;
});
});
// ─── materializeKeyframes ─────────────────────────────────────────────────────
describe("materializeKeyframes", () => {
it("adds keyframes property to flat tween", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "materializeKeyframes",
animationId: TWEEN_ANIM_ID,
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
],
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("keyframes");
expect(newScript).toContain('"0%"');
expect(newScript).toContain('"100%"');
});
it("injects easeEach into keyframes object", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "materializeKeyframes",
animationId: TWEEN_ANIM_ID,
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
],
easeEach: "power2.out",
});
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("easeEach");
expect(newScript).toContain("power2.out");
});
it("no-op when animation id not found", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "materializeKeyframes",
animationId: "nope",
keyframes: [{ percentage: 0, properties: { opacity: 0 } }],
});
expect(result.forward).toHaveLength(0);
});
});
// ─── splitIntoPropertyGroups ──────────────────────────────────────────────────
describe("splitIntoPropertyGroups", () => {
it("splits mixed tween into multiple group tweens", () => {
const script = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, opacity: 0.5, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
const parsed = fresh(script);
// mixed tween has no propertyGroup → no group suffix in id
const animId = `[data-hf-id="hf-box"]-to-0`;
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId });
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
// x is position group, opacity is visual group — expect 2 tweens
const toCount = (newScript.match(/\.to\(/g) ?? []).length;
expect(toCount).toBe(2);
});
it("no-op when animation id not found", () => {
const parsed = fresh();
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: "nope" });
expect(result.forward).toHaveLength(0);
});
it("no-op when tween has only one property group", () => {
// x + y = same "position" group → nothing to split
const script = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, y: 50, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
const parsed = fresh(script);
const animId = `[data-hf-id="hf-box"]-to-0-position`;
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId });
expect(result.forward).toHaveLength(0);
});
});
// ─── Label ops ────────────────────────────────────────────────────────────────
describe("addLabel", () => {
+51
View File
@@ -52,6 +52,8 @@ import {
removeKeyframeFromScript,
removeAllKeyframesFromScript,
convertToKeyframesFromScript,
materializeKeyframesFromScript,
splitIntoPropertyGroupsFromScript,
updateKeyframeInScript,
addLabelToScript,
removeLabelFromScript,
@@ -167,6 +169,16 @@ function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult
return handleRemoveAllKeyframes(parsed, op.animationId);
case "convertToKeyframes":
return handleConvertToKeyframes(parsed, op.animationId, op.resolvedFromValues);
case "materializeKeyframes":
return handleMaterializeKeyframes(
parsed,
op.animationId,
op.keyframes,
op.easeEach,
op.resolvedSelector,
);
case "splitIntoPropertyGroups":
return handleSplitIntoPropertyGroups(parsed, op.animationId);
default:
return undefined;
}
@@ -774,6 +786,43 @@ function handleConvertToKeyframes(
return gsapScriptChange(script, newScript);
}
function handleMaterializeKeyframes(
parsed: ParsedDocument,
animationId: string,
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>,
easeEach?: string,
resolvedSelector?: string,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const newScript = materializeKeyframesFromScript(
script,
animationId,
keyframes,
easeEach,
resolvedSelector,
);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleSplitIntoPropertyGroups(
parsed: ParsedDocument,
animationId: string,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const { script: newScript } = splitIntoPropertyGroupsFromScript(script, animationId);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
@@ -993,6 +1042,8 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
case "removeGsapTween":
case "removeAllKeyframes":
case "convertToKeyframes":
case "materializeKeyframes":
case "splitIntoPropertyGroups":
case "deleteAllForSelector":
case "removeLabel":
if (getGsapScript(parsed.document) === null)
+12
View File
@@ -112,6 +112,18 @@ export type EditOp =
resolvedFromValues?: Record<string, number | string>;
}
| { type: "deleteAllForSelector"; selector: string }
| {
type: "materializeKeyframes";
animationId: string;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>;
easeEach?: string;
resolvedSelector?: string;
}
| { type: "splitIntoPropertyGroups"; animationId: string }
| { type: "addLabel"; name: string; position: number }
| { type: "removeLabel"; name: string };