Skip to content

Commit ccd350d

Browse files
feat(sdk,core): ws-3 — splitAnimationsInScript acorn port + SDK op
- acorn: updateAnimationSelectorInScript, insertInheritedStateSetInScript helpers - acorn: splitAnimationsInScript exported (parity with recast version) - parity: 4 new fixtures (3 cases + no-op) — 23 total parity tests - SDK types: splitAnimations EditOp variant - mutate.ts: handleSplitAnimations + can() gate - mutate.gsap.test.ts: 3 new tests (56 total passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent 7134b0f commit ccd350d

5 files changed

Lines changed: 344 additions & 0 deletions

File tree

packages/core/src/parsers/gsapWriter.parity.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ import {
1414
convertToKeyframesInScript as convertRecast,
1515
materializeKeyframesInScript as materializeRecast,
1616
splitIntoPropertyGroups as splitGroupsRecast,
17+
splitAnimationsInScript as splitAnimsRecast,
18+
type SplitAnimationsOptions,
1719
} from "./gsapParser.js";
1820
import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js";
1921
import {
2022
removeAllKeyframesFromScript as removeAllAcorn,
2123
convertToKeyframesFromScript as convertAcorn,
2224
materializeKeyframesFromScript as materializeAcorn,
2325
splitIntoPropertyGroupsFromScript as splitGroupsAcorn,
26+
splitAnimationsInScript as splitAnimsAcorn,
2427
} from "./gsapWriterAcorn.js";
2528

2629
function acornId(script: string): string {
@@ -301,3 +304,89 @@ describe("parity: splitIntoPropertyGroupsFromScript (recast vs acorn)", () => {
301304
expect(out).toBe(script);
302305
});
303306
});
307+
308+
// ── splitAnimationsInScript parity ────────────────────────────────────────────
309+
310+
function animShapesOf(script: string) {
311+
return parseGsapScript(script).animations.map((a) => ({
312+
method: a.method,
313+
selector: a.targetSelector,
314+
properties: a.properties,
315+
fromProperties: a.fromProperties,
316+
duration: a.duration,
317+
position: a.position,
318+
}));
319+
}
320+
321+
const SPLIT_ANIM_CASES: Array<{ name: string; script: string; opts: SplitAnimationsOptions }> = [
322+
{
323+
name: "all tweens before split — retargets none",
324+
script: `
325+
const tl = gsap.timeline({ paused: true });
326+
tl.to("#hero", { x: 100, duration: 1 }, 0);
327+
`,
328+
opts: {
329+
originalId: "hero",
330+
newId: "hero-2",
331+
splitTime: 2,
332+
elementStart: 0,
333+
elementDuration: 4,
334+
},
335+
},
336+
{
337+
name: "tween entirely after split — retargeted to newId",
338+
script: `
339+
const tl = gsap.timeline({ paused: true });
340+
tl.to("#hero", { opacity: 0, duration: 0.5 }, 3);
341+
`,
342+
opts: {
343+
originalId: "hero",
344+
newId: "hero-2",
345+
splitTime: 2,
346+
elementStart: 0,
347+
elementDuration: 4,
348+
},
349+
},
350+
{
351+
name: "tween spanning split — truncated first half + fromTo second half",
352+
script: `
353+
const tl = gsap.timeline({ paused: true });
354+
tl.to("#hero", { x: 200, duration: 4 }, 0);
355+
`,
356+
opts: {
357+
originalId: "hero",
358+
newId: "hero-2",
359+
splitTime: 2,
360+
elementStart: 0,
361+
elementDuration: 4,
362+
},
363+
},
364+
];
365+
366+
describe("parity: splitAnimationsInScript (recast vs acorn)", () => {
367+
for (const { name, script, opts } of SPLIT_ANIM_CASES) {
368+
it(name, () => {
369+
const { script: recastOut } = splitAnimsRecast(script, opts);
370+
const { script: acornOut } = splitAnimsAcorn(script, opts);
371+
const sortByPos = (arr: ReturnType<typeof animShapesOf>) =>
372+
arr.slice().sort((a, b) => {
373+
const pa = typeof a.position === "number" ? a.position : 0;
374+
const pb = typeof b.position === "number" ? b.position : 0;
375+
return pa - pb || (a.selector ?? "").localeCompare(b.selector ?? "");
376+
});
377+
expect(sortByPos(animShapesOf(acornOut))).toEqual(sortByPos(animShapesOf(recastOut)));
378+
});
379+
}
380+
381+
it("no-op when originalId not found in script", () => {
382+
const script = SPLIT_ANIM_CASES[0]!.script;
383+
const opts: SplitAnimationsOptions = {
384+
originalId: "nonexistent",
385+
newId: "x",
386+
splitTime: 2,
387+
elementStart: 0,
388+
elementDuration: 4,
389+
};
390+
expect(splitAnimsAcorn(script, opts).script).toBe(script);
391+
});
392+
});

packages/core/src/parsers/gsapWriterAcorn.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "./gsapParserAcorn.js";
1717
import { classifyPropertyGroup } from "./gsapConstants.js";
1818
import type { PropertyGroupName } from "./gsapConstants.js";
19+
import type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapParser.js";
1920
import * as acornWalk from "acorn-walk";
2021

2122
// ── Code generation helpers ──────────────────────────────────────────────────
@@ -1172,3 +1173,169 @@ export function removeLabelFromScript(script: string, name: string): string {
11721173
}
11731174
return ms.toString();
11741175
}
1176+
1177+
// ── splitAnimationsInScript helpers ──────────────────────────────────────────
1178+
1179+
/** Overwrite the selector (first arg) of a tween call. */
1180+
function updateAnimationSelectorInScript(
1181+
script: string,
1182+
animationId: string,
1183+
newSelector: string,
1184+
): string {
1185+
const parsed = parseGsapScriptAcornForWrite(script);
1186+
if (!parsed) return script;
1187+
const target = parsed.located.find((l) => l.id === animationId);
1188+
if (!target) return script;
1189+
const selectorArg = target.call.node.arguments?.[0];
1190+
if (!selectorArg) return script;
1191+
const ms = new MagicString(script);
1192+
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(newSelector));
1193+
return ms.toString();
1194+
}
1195+
1196+
/**
1197+
* Insert a `tl.set()` call immediately after the timeline declaration
1198+
* (before existing tweens) to establish inherited state on a new element.
1199+
*/
1200+
function insertInheritedStateSetInScript(
1201+
script: string,
1202+
selector: string,
1203+
position: number,
1204+
properties: Record<string, number | string>,
1205+
): string {
1206+
const parsed = parseGsapScriptAcornForWrite(script);
1207+
if (!parsed) return script;
1208+
const props = Object.entries(properties)
1209+
.map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`)
1210+
.join(", ");
1211+
const code = `${parsed.timelineVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`;
1212+
const ms = new MagicString(script);
1213+
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
1214+
if (tlDecl) {
1215+
ms.appendLeft(tlDecl.end, "\n" + code);
1216+
} else if (parsed.located.length > 0) {
1217+
const firstCall = parsed.located[0]!.call;
1218+
const exprStmt = findEnclosingExpressionStatement(firstCall.ancestors);
1219+
const insertAt = exprStmt?.start ?? firstCall.node.start;
1220+
ms.prependLeft(insertAt, code + "\n");
1221+
} else {
1222+
ms.append("\n" + code);
1223+
}
1224+
return ms.toString();
1225+
}
1226+
1227+
// fallow-ignore-next-line complexity
1228+
export function splitAnimationsInScript(
1229+
script: string,
1230+
opts: SplitAnimationsOptions,
1231+
): SplitAnimationsResult {
1232+
const parsed = parseGsapScriptAcornForWrite(script);
1233+
if (!parsed) return { script, skippedSelectors: [] };
1234+
1235+
const originalSelector = `#${opts.originalId}`;
1236+
const newSelector = `#${opts.newId}`;
1237+
1238+
const animations = parsed.located.map((l) => l.animation);
1239+
const skippedSelectors: string[] = [];
1240+
1241+
for (const a of animations) {
1242+
if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) {
1243+
skippedSelectors.push(a.targetSelector);
1244+
}
1245+
}
1246+
1247+
const matching = animations.filter((a) => a.targetSelector === originalSelector);
1248+
if (matching.length === 0) return { script, skippedSelectors };
1249+
1250+
let result = script;
1251+
const newElementStart = opts.splitTime;
1252+
const inheritedProps: Record<string, number | string> = {};
1253+
1254+
// Reverse iteration: updateAnimationSelectorInScript mutates selectors which
1255+
// can shift count-based ID suffixes for later animations.
1256+
for (let i = matching.length - 1; i >= 0; i--) {
1257+
const anim = matching[i]!;
1258+
const pos = typeof anim.position === "number" ? anim.position : 0;
1259+
const dur = anim.duration ?? 0;
1260+
const animEnd = pos + dur;
1261+
1262+
if (anim.keyframes) {
1263+
if (pos >= opts.splitTime) {
1264+
result = updateAnimationSelectorInScript(result, anim.id, newSelector);
1265+
} else if (animEnd > opts.splitTime) {
1266+
skippedSelectors.push(`${originalSelector} (keyframes spanning split)`);
1267+
const kfs = anim.keyframes.keyframes;
1268+
for (const kf of kfs) {
1269+
const kfTime = pos + (kf.percentage / 100) * dur;
1270+
if (kfTime <= opts.splitTime) {
1271+
for (const [k, v] of Object.entries(kf.properties)) {
1272+
inheritedProps[k] = v;
1273+
}
1274+
}
1275+
}
1276+
} else {
1277+
const kfs = anim.keyframes.keyframes;
1278+
if (kfs.length > 0) {
1279+
for (const [k, v] of Object.entries(kfs[kfs.length - 1]!.properties)) {
1280+
inheritedProps[k] = v;
1281+
}
1282+
}
1283+
}
1284+
continue;
1285+
}
1286+
1287+
if (animEnd <= opts.splitTime) {
1288+
for (const [k, v] of Object.entries(anim.properties)) {
1289+
inheritedProps[k] = v;
1290+
}
1291+
continue;
1292+
}
1293+
1294+
if (pos >= opts.splitTime) {
1295+
result = updateAnimationSelectorInScript(result, anim.id, newSelector);
1296+
continue;
1297+
}
1298+
1299+
// Spans the split — linear interpolation to compute mid-values.
1300+
const progress = dur > 0 ? (opts.splitTime - pos) / dur : 0;
1301+
const fromSource = anim.fromProperties ?? inheritedProps;
1302+
const midProps: Record<string, number | string> = {};
1303+
for (const [k, v] of Object.entries(anim.properties)) {
1304+
if (typeof v !== "number") {
1305+
midProps[k] = v;
1306+
continue;
1307+
}
1308+
const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0;
1309+
midProps[k] = fromVal + (v - fromVal) * progress;
1310+
}
1311+
1312+
const firstHalfDuration = opts.splitTime - pos;
1313+
result = updateAnimationInScript(result, anim.id, {
1314+
duration: firstHalfDuration,
1315+
properties: midProps,
1316+
});
1317+
1318+
const secondHalfDuration = animEnd - opts.splitTime;
1319+
const addResult = addAnimationToScript(result, {
1320+
targetSelector: newSelector,
1321+
method: "fromTo",
1322+
position: newElementStart,
1323+
duration: secondHalfDuration,
1324+
properties: { ...anim.properties },
1325+
fromProperties: { ...midProps },
1326+
ease: anim.ease,
1327+
extras: anim.extras,
1328+
});
1329+
result = addResult.script;
1330+
1331+
for (const [k, v] of Object.entries(midProps)) {
1332+
inheritedProps[k] = v;
1333+
}
1334+
}
1335+
1336+
if (Object.keys(inheritedProps).length > 0) {
1337+
result = insertInheritedStateSetInScript(result, newSelector, newElementStart, inheritedProps);
1338+
}
1339+
1340+
return { script: result, skippedSelectors };
1341+
}

packages/sdk/src/engine/mutate.gsap.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,64 @@ window.__timelines["t"] = tl;`;
584584
});
585585
});
586586

587+
// ─── splitAnimations ──────────────────────────────────────────────────────────
588+
589+
describe("splitAnimations", () => {
590+
const SPLIT_SCRIPT = `var tl = gsap.timeline({ paused: true });
591+
tl.to("#hero", { x: 200, duration: 4 }, 0);
592+
window.__timelines["t"] = tl;`;
593+
594+
function freshSplit() {
595+
return parseMutable(`<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
596+
<div data-hf-id="hf-hero"></div>
597+
<script>${SPLIT_SCRIPT}</script>
598+
</div>`);
599+
}
600+
601+
it("retargets post-split tween to newId", () => {
602+
const parsed = freshSplit();
603+
const result = applyOp(parsed, {
604+
type: "splitAnimations",
605+
originalId: "hero",
606+
newId: "hero-2",
607+
splitTime: 3,
608+
elementStart: 0,
609+
elementDuration: 4,
610+
});
611+
expect(result.forward).toHaveLength(1);
612+
const newScript = String(result.forward[0]?.value ?? "");
613+
expect(newScript).toContain("#hero-2");
614+
});
615+
616+
it("spanning tween produces fromTo on new element", () => {
617+
const parsed = freshSplit();
618+
const result = applyOp(parsed, {
619+
type: "splitAnimations",
620+
originalId: "hero",
621+
newId: "hero-2",
622+
splitTime: 2,
623+
elementStart: 0,
624+
elementDuration: 4,
625+
});
626+
const newScript = String(result.forward[0]?.value ?? "");
627+
expect(newScript).toContain(".fromTo(");
628+
expect(newScript).toContain("#hero-2");
629+
});
630+
631+
it("no-op when originalId not found", () => {
632+
const parsed = freshSplit();
633+
const result = applyOp(parsed, {
634+
type: "splitAnimations",
635+
originalId: "nonexistent",
636+
newId: "x",
637+
splitTime: 2,
638+
elementStart: 0,
639+
elementDuration: 4,
640+
});
641+
expect(result.forward).toHaveLength(0);
642+
});
643+
});
644+
587645
// ─── Label ops ────────────────────────────────────────────────────────────────
588646

589647
describe("addLabel", () => {

packages/sdk/src/engine/mutate.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
convertToKeyframesFromScript,
5454
materializeKeyframesFromScript,
5555
splitIntoPropertyGroupsFromScript,
56+
splitAnimationsInScript,
5657
updateKeyframeInScript,
5758
addLabelToScript,
5859
removeLabelFromScript,
@@ -178,6 +179,8 @@ function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult
178179
);
179180
case "splitIntoPropertyGroups":
180181
return handleSplitIntoPropertyGroups(parsed, op.animationId);
182+
case "splitAnimations":
183+
return handleSplitAnimations(parsed, op);
181184
default:
182185
return undefined;
183186
}
@@ -793,6 +796,24 @@ function handleSplitIntoPropertyGroups(
793796
return gsapScriptChange(script, newScript);
794797
}
795798

799+
function handleSplitAnimations(
800+
parsed: ParsedDocument,
801+
op: Extract<EditOp, { type: "splitAnimations" }>,
802+
): MutationResult {
803+
const script = getGsapScript(parsed.document);
804+
if (!script) return EMPTY;
805+
const { script: newScript } = splitAnimationsInScript(script, {
806+
originalId: op.originalId,
807+
newId: op.newId,
808+
splitTime: op.splitTime,
809+
elementStart: op.elementStart,
810+
elementDuration: op.elementDuration,
811+
});
812+
if (newScript === script) return EMPTY;
813+
setGsapScript(parsed.document, newScript);
814+
return gsapScriptChange(script, newScript);
815+
}
816+
796817
function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult {
797818
const script = getGsapScript(parsed.document);
798819
if (!script) return EMPTY;
@@ -1014,6 +1035,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
10141035
case "convertToKeyframes":
10151036
case "materializeKeyframes":
10161037
case "splitIntoPropertyGroups":
1038+
case "splitAnimations":
10171039
case "deleteAllForSelector":
10181040
case "removeLabel":
10191041
if (getGsapScript(parsed.document) === null)

0 commit comments

Comments
 (0)