mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core): acorn GSAP write path — magic-string offset-splice (T6c) (#1369)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user