feat(core): acorn GSAP write path — magic-string offset-splice (T6c) (#1369)

This commit is contained in:
Vance Ingalls
2026-06-15 00:38:10 -07:00
committed by GitHub
parent be4a28ae72
commit 0fbda8acff
5 changed files with 695 additions and 1 deletions
+1
View File
@@ -216,6 +216,7 @@
"acorn": "^8.17.0",
"acorn-walk": "^8.3.5",
"bpm-detective": "^2.0.5",
"magic-string": "^0.30.21",
"postcss": "^8.5.8",
"postcss-selector-parser": "^7.1.2",
"recast": "^0.23.11"
+41 -1
View File
@@ -419,7 +419,7 @@ const EXTRAS_KEYS = new Set([
"immediateRender",
]);
interface TweenCallInfo {
export interface TweenCallInfo {
node: any;
/** acorn-walk ancestor array at the call site (root→call, call is last). */
ancestors: any[];
@@ -1041,6 +1041,46 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
});
}
// ── Write-path internal parse ─────────────────────────────────────────────────
export interface ParsedGsapAcornForWrite {
ast: any;
timelineVar: string;
located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>;
}
/**
* Parse a GSAP script and return internal AST + call nodes for the write path.
* Consumed by gsapWriterAcorn.ts (magic-string offset-splice).
*/
export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornForWrite | null {
try {
const ast = acorn.parse(script, {
ecmaVersion: "latest",
sourceType: "script",
locations: true,
});
const scope = collectScopeBindings(ast);
const targetBindings = collectTargetBindings(ast, scope);
const detection = findTimelineVar(ast, scope);
const timelineVar = detection.timelineVar ?? "tl";
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
sortBySourcePosition(calls);
const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script));
applyTimelineDefaults(rawAnims, detection.defaults);
resolveTimelinePositions(rawAnims);
const animations = assignStableIds(rawAnims);
const located = calls.map((call, i) => ({
id: animations[i]!.id,
call,
animation: animations[i]!,
}));
return { ast, timelineVar, located };
} catch {
return null;
}
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
@@ -0,0 +1,284 @@
// fallow-ignore-file duplication
/**
* T6c — acorn write path with magic-string offset-splice.
*
* Verifies that each write op touches only the intended byte span and leaves
* every other character identical to the original source.
*/
import { describe, expect, it } from "vitest";
import {
addAnimationToScript,
addKeyframeToScript,
removeAnimationFromScript,
removeKeyframeFromScript,
updateAnimationInScript,
updateKeyframeInScript,
} from "./gsapWriterAcorn.js";
// ---------------------------------------------------------------------------
// Fixture scripts
// ---------------------------------------------------------------------------
const SCRIPT_A = `\
var tl = gsap.timeline({ paused: true });
tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
window.__timelines["t"] = tl;`;
const SCRIPT_B = `\
var tl = gsap.timeline({ paused: true });
tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0);
tl.to("#hero", { opacity: 0, duration: 0.3, ease: "power3.in" }, 1);
window.__timelines["t"] = tl;`;
const SCRIPT_C = `\
var tl = gsap.timeline({ paused: true });
tl.from(".a", { opacity: 0, duration: 0.5 }, 0)
.from(".b", { opacity: 0, duration: 0.3 }, 0.5);
window.__timelines["t"] = tl;`;
// 3-keyframe script so removal leaves ≥2 kfs (no collapse needed)
const SCRIPT_D = `\
var tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;`;
// ---------------------------------------------------------------------------
// No-op identity
// ---------------------------------------------------------------------------
describe("T6c — no-op identity", () => {
it("updateAnimationInScript with empty updates returns identical script", () => {
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {});
expect(result).toBe(SCRIPT_A);
});
it("updateAnimationInScript with unknown ID returns identical script", () => {
const result = updateAnimationInScript(SCRIPT_A, "not-a-real-id", { ease: "power2.in" });
expect(result).toBe(SCRIPT_A);
});
});
// ---------------------------------------------------------------------------
// updateAnimationInScript
// ---------------------------------------------------------------------------
describe("T6c — updateAnimationInScript", () => {
it("updates ease value in-place", () => {
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
ease: "power2.in",
});
expect(result).toContain('"power2.in"');
expect(result).not.toContain('"power3.out"');
// Preamble + postamble unchanged
expect(result).toContain("var tl = gsap.timeline({ paused: true });");
expect(result).toContain('window.__timelines["t"] = tl;');
});
it("updates duration value in-place", () => {
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
duration: 1.2,
});
expect(result).toContain("duration: 1.2");
expect(result).not.toContain("duration: 0.5");
expect(result).toContain('"power3.out"');
});
it("updates position arg in-place", () => {
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
position: 0.5,
});
expect(result).toContain("}, 0.5)");
expect(result).not.toContain("}, 0.2)");
expect(result).toContain("opacity: 1");
});
it("inserts ease when property was absent", () => {
const noEase = `\
var tl = gsap.timeline({ paused: true });
tl.to("#hero", { opacity: 1, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;`;
const result = updateAnimationInScript(noEase, "#hero-to-200-visual", {
ease: "power3.out",
});
expect(result).toContain('ease: "power3.out"');
// Duration, opacity, position unchanged
expect(result).toContain("duration: 0.5");
expect(result).toContain("opacity: 1");
expect(result).toContain("}, 0.2)");
});
it("updates fromTo — ease on toVars", () => {
const fromTo = `\
var tl = gsap.timeline({ paused: true });
tl.fromTo("#hero", { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1);
window.__timelines["t"] = tl;`;
// ID: target="#hero", method="fromTo", pos=0.1 → posKey=100, propertyGroup=visual
const result = updateAnimationInScript(fromTo, "#hero-fromTo-100-visual", {
ease: "back.out",
});
expect(result).toContain('"back.out"');
expect(result).not.toContain('"power3.out"');
expect(result).toContain("opacity: 0");
});
it("byte-identity outside edited ease span", () => {
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
ease: "power2.in",
});
const oldEaseStart = SCRIPT_A.indexOf('"power3.out"');
const newEaseStart = result.indexOf('"power2.in"');
// Everything before the ease value is identical
expect(result.slice(0, newEaseStart)).toBe(SCRIPT_A.slice(0, oldEaseStart));
// Everything after the ease value close-quote is identical
const oldAfter = SCRIPT_A.slice(oldEaseStart + '"power3.out"'.length);
const newAfter = result.slice(newEaseStart + '"power2.in"'.length);
expect(newAfter).toBe(oldAfter);
});
});
// ---------------------------------------------------------------------------
// removeAnimationFromScript
// ---------------------------------------------------------------------------
describe("T6c — removeAnimationFromScript", () => {
it("removes a standalone tween statement", () => {
const result = removeAnimationFromScript(SCRIPT_B, "#hero-to-0-visual");
expect(result).not.toContain("power3.out");
expect(result).toContain("power3.in");
expect(result).toContain('window.__timelines["t"] = tl;');
});
it("removes last chain link (outer call)", () => {
// SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5)
// Remove .b (outermost call = last in source)
const result = removeAnimationFromScript(SCRIPT_C, ".b-from-500-visual");
expect(result).toContain('.from(".a"');
expect(result).not.toContain('.from(".b"');
// The statement should still end with ; (no dangling chain)
expect(result).toContain("}, 0);");
});
it("removes inner chain link", () => {
// SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5)
// Remove .a (innermost call = first in source)
const result = removeAnimationFromScript(SCRIPT_C, ".a-from-0-visual");
expect(result).not.toContain('.from(".a"');
expect(result).toContain('.from(".b"');
// Chain is still rooted at tl (whitespace between tl and .from is valid JS)
expect(result).toMatch(/tl[\s.]*from\("\.b"/);
});
it("unknown ID returns script unchanged", () => {
const result = removeAnimationFromScript(SCRIPT_A, "nonexistent-id");
expect(result).toBe(SCRIPT_A);
});
});
// ---------------------------------------------------------------------------
// addAnimationToScript
// ---------------------------------------------------------------------------
describe("T6c — addAnimationToScript", () => {
it("inserts new tween after last existing tween", () => {
const { script: result } = addAnimationToScript(SCRIPT_A, {
targetSelector: "#new",
method: "to",
position: 0.5,
duration: 0.3,
properties: { x: 100 },
});
expect(result).toContain('tl.to("#new"');
expect(result).toContain("x: 100");
expect(result).toContain("duration: 0.3");
// Original content preserved
expect(result).toContain('tl.to("#hero"');
expect(result).toContain('window.__timelines["t"] = tl;');
// New tween comes after hero tween
expect(result.indexOf('tl.to("#new"')).toBeGreaterThan(result.indexOf('tl.to("#hero"'));
});
it("returns a non-empty stable id for the new animation", () => {
const { id } = addAnimationToScript(SCRIPT_A, {
targetSelector: "#new",
method: "to",
position: 0.5,
duration: 0.3,
properties: { x: 100 },
});
expect(id).toBeTruthy();
expect(typeof id).toBe("string");
});
it("inserts after timeline declaration when script has no tweens", () => {
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
const { script: result } = addAnimationToScript(empty, {
targetSelector: "#hero",
method: "to",
position: 0,
duration: 0.5,
properties: { opacity: 1 },
});
expect(result).toContain('tl.to("#hero"');
// Inserted after timeline declaration
expect(result.indexOf('tl.to("#hero"')).toBeGreaterThan(result.indexOf("gsap.timeline"));
});
});
// ---------------------------------------------------------------------------
// Keyframe write ops
// ---------------------------------------------------------------------------
describe("T6c — keyframe write ops", () => {
it("updateKeyframeInScript replaces keyframe value at given percentage", () => {
// Update 50% from { opacity: 0.7 } to { opacity: 0.5 }
const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.5 });
expect(result).toContain("opacity: 0.5");
expect(result).not.toContain("opacity: 0.7");
// Other keyframes unchanged
expect(result).toContain('"0%": { opacity: 0 }');
expect(result).toContain('"100%": { opacity: 1 }');
});
it("updateKeyframeInScript preserves bytes outside the edited value", () => {
const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 100, {
opacity: 0.9,
});
// The 50% keyframe is untouched
expect(result).toContain('"50%": { opacity: 0.7 }');
// Duration and position are unchanged
expect(result).toContain("duration: 0.5");
expect(result).toContain("}, 0.2)");
});
it("addKeyframeToScript inserts new percentage in sorted order", () => {
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 25, { opacity: 0.3 });
expect(result).toContain('"25%"');
expect(result).toContain("opacity: 0.3");
// Original keyframes preserved
expect(result).toContain('"0%": { opacity: 0 }');
expect(result).toContain('"50%": { opacity: 0.7 }');
// 25% appears before 50% in the string
expect(result.indexOf('"25%"')).toBeLessThan(result.indexOf('"50%"'));
});
it("addKeyframeToScript replaces value when percentage already exists", () => {
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.99 });
expect(result).toContain("opacity: 0.99");
expect(result).not.toContain("opacity: 0.7");
// Only one "50%" in the result
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
});
it("removeKeyframeFromScript removes the target percentage", () => {
// Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c)
const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50);
expect(result).not.toContain('"50%"');
expect(result).toContain('"0%"');
expect(result).toContain('"100%"');
});
it("updateKeyframeInScript on unknown id returns script unchanged", () => {
const result = updateKeyframeInScript(SCRIPT_D, "bad-id", 50, { opacity: 0.5 });
expect(result).toBe(SCRIPT_D);
});
});
@@ -0,0 +1,368 @@
// fallow-ignore-file duplication
/**
* Browser-safe GSAP write path — magic-string offset-splice.
*
* T6c: edits GSAP scripts by overwriting/removing byte ranges in the original
* source. Every byte outside the edited span is preserved verbatim — no
* pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts.
*/
import MagicString from "magic-string";
import type { GsapAnimation } from "./gsapSerialize.js";
import { parseGsapScriptAcornForWrite, type TweenCallInfo } from "./gsapParserAcorn.js";
import * as acornWalk from "acorn-walk";
// ── Code generation helpers ──────────────────────────────────────────────────
function valueToCode(value: number | string): string {
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
if (typeof value === "string") return JSON.stringify(value);
return String(value);
}
function safeKey(key: string): string {
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
}
// fallow-ignore-next-line complexity
function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation, "id">): string {
const selector = JSON.stringify(anim.targetSelector);
const props: Record<string, number | string> = { ...anim.properties };
if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (anim.extras) {
for (const [k, v] of Object.entries(anim.extras)) {
entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
}
}
const objCode = `{ ${entries.join(", ")} }`;
const posCode = valueToCode(
typeof anim.position === "number" ? anim.position : (anim.position ?? 0),
);
if (anim.method === "fromTo") {
const fromEntries = Object.entries(anim.fromProperties ?? {}).map(
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
);
return `${timelineVar}.fromTo(${selector}, { ${fromEntries.join(", ")} }, ${objCode}, ${posCode});`;
}
return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`;
}
// ── AST node helpers ─────────────────────────────────────────────────────────
function isObjectProperty(prop: any): boolean {
return prop?.type === "ObjectProperty" || prop?.type === "Property";
}
function propKeyName(prop: any): string | undefined {
return prop?.key?.name ?? prop?.key?.value;
}
function findPropertyNode(varsArgNode: any, key: string): any | undefined {
if (varsArgNode?.type !== "ObjectExpression") return undefined;
for (const prop of varsArgNode.properties ?? []) {
if (!isObjectProperty(prop)) continue;
if (propKeyName(prop) === key) return prop;
}
return undefined;
}
function findEnclosingExpressionStatement(ancestors: any[]): any | null {
for (let i = ancestors.length - 2; i >= 0; i--) {
if (ancestors[i]?.type === "ExpressionStatement") return ancestors[i];
}
return null;
}
/** Find the VariableDeclaration statement for `tl = gsap.timeline(...)`. */
function findTimelineDeclarationStatement(ast: any, timelineVar: string): any | null {
let found: any = null;
acornWalk.simple(ast, {
// fallow-ignore-next-line complexity
VariableDeclaration(node: any) {
if (found) return;
for (const decl of node.declarations ?? []) {
if (
decl.id?.name === timelineVar &&
decl.init?.type === "CallExpression" &&
decl.init.callee?.type === "MemberExpression" &&
decl.init.callee.object?.name === "gsap" &&
decl.init.callee.property?.name === "timeline"
) {
found = node;
}
}
},
});
return found;
}
// ── Property splice helpers ───────────────────────────────────────────────────
/**
* Remove a property from a properties array, handling its comma.
* `editableProps` must be the isObjectProperty-filtered subset in source order.
*/
function removeProp(ms: MagicString, propNode: any, editableProps: any[]): void {
const idx = editableProps.indexOf(propNode);
if (idx === -1) return;
if (editableProps.length === 1) {
ms.remove(propNode.start, propNode.end);
} else if (idx === 0) {
// First prop: remove from its start to next prop start (drops trailing ", ")
ms.remove(editableProps[0].start, editableProps[1].start);
} else {
// Non-first: remove from prev prop end to this prop end (drops leading ", ")
ms.remove(editableProps[idx - 1].end, propNode.end);
}
}
/**
* Update a property value if it exists, or append a new key: val before the
* closing `}`. Call with the full ObjectExpression node.
*/
function upsertProp(ms: MagicString, objNode: any, key: string, value: number | string): void {
if (objNode?.type !== "ObjectExpression") return;
const existing = findPropertyNode(objNode, key);
if (existing) {
ms.overwrite(existing.value.start, existing.value.end, valueToCode(value));
} else {
const sep = objNode.properties.length > 0 ? ", " : "";
ms.appendLeft(objNode.end - 1, `${sep}${safeKey(key)}: ${valueToCode(value)}`);
}
}
// ── Public write API ─────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
export function updateAnimationInScript(
script: string,
animationId: string,
updates: Partial<GsapAnimation>,
): string {
if (!Object.keys(updates).length) return script;
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const ms = new MagicString(script);
const { call }: { call: TweenCallInfo } = target;
if (updates.duration !== undefined) {
upsertProp(ms, call.varsArg, "duration", updates.duration);
}
if (updates.ease !== undefined) {
upsertProp(ms, call.varsArg, "ease", updates.ease);
}
if (updates.properties) {
for (const [key, value] of Object.entries(updates.properties)) {
upsertProp(ms, call.varsArg, key, value);
}
}
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
for (const [key, value] of Object.entries(updates.fromProperties)) {
upsertProp(ms, call.fromArg, key, value);
}
}
if (updates.position !== undefined) {
const posIdx = call.method === "fromTo" ? 3 : 2;
const posArgNode = call.node.arguments?.[posIdx];
if (posArgNode) {
ms.overwrite(posArgNode.start, posArgNode.end, valueToCode(updates.position));
} else {
ms.appendLeft(call.node.end - 1, `, ${valueToCode(updates.position)}`);
}
}
return ms.toString();
}
export function addAnimationToScript(
script: string,
animation: Omit<GsapAnimation, "id">,
): { script: string; id: string } {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return { script, id: "" };
const ms = new MagicString(script);
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
let insertionPoint: number;
if (parsed.located.length > 0) {
const lastCall = parsed.located[parsed.located.length - 1]!.call;
const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors);
insertionPoint = exprStmt?.end ?? lastCall.node.end;
} else {
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
insertionPoint = tlDecl?.end ?? script.length;
}
ms.appendLeft(insertionPoint, "\n" + statementCode);
const result = ms.toString();
const reParsed = parseGsapScriptAcornForWrite(result);
const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? "";
return { script: result, id: newId };
}
export function removeAnimationFromScript(script: string, animationId: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
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 ms = new MagicString(script);
const N = target.call.node;
const exprStmt = findEnclosingExpressionStatement(target.call.ancestors);
if (N.callee?.object?.type !== "CallExpression" && exprStmt?.expression === N) {
// Standalone `tl.method(...)` — remove the whole ExpressionStatement
const end =
exprStmt.end < script.length && script[exprStmt.end] === "\n"
? exprStmt.end + 1
: exprStmt.end;
ms.remove(exprStmt.start, end);
} else {
// Chain link — splice out `.method(args)` from N.callee.object.end to N.end
ms.remove(N.callee.object.end, N.end);
}
return ms.toString();
}
// ── Keyframe write ops ────────────────────────────────────────────────────────
const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
function percentageFromKey(key: string): number {
const m = PERCENTAGE_KEY_RE.exec(key);
return m ? Number.parseFloat(m[1] ?? "0") : Number.NaN;
}
function buildKeyframeValueCode(
properties: Record<string, number | string>,
ease?: string,
): string {
const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (ease) entries.push(`ease: ${JSON.stringify(ease)}`);
return `{ ${entries.join(", ")} }`;
}
function findKfPropByPct(kfNode: any, percentage: number): { prop: any; idx: number } | null {
const props = kfNode.properties ?? [];
for (let i = 0; i < props.length; i++) {
const prop = props[i];
if (!isObjectProperty(prop)) continue;
const key = propKeyName(prop);
if (typeof key === "string" && Math.abs(percentageFromKey(key) - percentage) < 0.001) {
return { prop, idx: i };
}
}
return null;
}
export function updateKeyframeInScript(
script: string,
animationId: string,
percentage: number,
properties: Record<string, number | string>,
ease?: string,
): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script;
const match = findKfPropByPct(kfPropNode.value, percentage);
if (!match) return script;
const ms = new MagicString(script);
ms.overwrite(
match.prop.value.start,
match.prop.value.end,
buildKeyframeValueCode(properties, ease),
);
return ms.toString();
}
// fallow-ignore-next-line complexity
export function addKeyframeToScript(
script: string,
animationId: string,
percentage: number,
properties: Record<string, number | string>,
ease?: string,
): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script;
const kfNode = kfPropNode.value;
const ms = new MagicString(script);
const pctKey = `${percentage}%`;
const valueCode = buildKeyframeValueCode(properties, ease);
const existing = findKfPropByPct(kfNode, percentage);
if (existing) {
ms.overwrite(existing.prop.value.start, existing.prop.value.end, valueCode);
} else {
const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p));
let insertBeforeProp: any = null;
for (const prop of allProps) {
const key = propKeyName(prop);
if (typeof key === "string" && percentageFromKey(key) > percentage) {
insertBeforeProp = prop;
break;
}
}
if (insertBeforeProp) {
// Insert `"pct%": {...}, ` before the next higher-percentage prop
ms.appendLeft(insertBeforeProp.start, `${JSON.stringify(pctKey)}: ${valueCode}, `);
} else {
// Append at end of kfNode properties
const sep = allProps.length > 0 ? ", " : "";
ms.appendLeft(kfNode.end - 1, `${sep}${JSON.stringify(pctKey)}: ${valueCode}`);
}
}
return ms.toString();
}
export function removeKeyframeFromScript(
script: string,
animationId: string,
percentage: number,
): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script;
const kfNode = kfPropNode.value;
const match = findKfPropByPct(kfNode, percentage);
if (!match) return script;
const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p));
const ms = new MagicString(script);
removeProp(ms, match.prop, allProps);
return ms.toString();
}