mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core): GSAP keyframe parsing, mutations, and API routes (#1167)
This commit is contained in:
@@ -6,17 +6,34 @@
|
||||
*/
|
||||
|
||||
export const SUPPORTED_PROPS = [
|
||||
"opacity",
|
||||
"visibility",
|
||||
// Transforms
|
||||
"x",
|
||||
"y",
|
||||
"scale",
|
||||
"scaleX",
|
||||
"scaleY",
|
||||
"rotation",
|
||||
"skewX",
|
||||
"skewY",
|
||||
// Visibility
|
||||
"opacity",
|
||||
"visibility",
|
||||
"autoAlpha",
|
||||
// Dimensions
|
||||
"width",
|
||||
"height",
|
||||
// Colors
|
||||
"color",
|
||||
"backgroundColor",
|
||||
"borderColor",
|
||||
// Box model
|
||||
"borderRadius",
|
||||
// Typography
|
||||
"fontSize",
|
||||
"letterSpacing",
|
||||
// Filter & Clipping
|
||||
"filter",
|
||||
"clipPath",
|
||||
];
|
||||
|
||||
export const SUPPORTED_EASES = [
|
||||
@@ -45,4 +62,9 @@ export const SUPPORTED_EASES = [
|
||||
"expo.in",
|
||||
"expo.out",
|
||||
"expo.inOut",
|
||||
"spring-gentle",
|
||||
"spring-bouncy",
|
||||
"spring-stiff",
|
||||
"spring-wobbly",
|
||||
"spring-heavy",
|
||||
];
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
addAnimationToScript,
|
||||
removeAnimationFromScript,
|
||||
updateAnimationInScript,
|
||||
addKeyframeToScript,
|
||||
removeKeyframeFromScript,
|
||||
updateKeyframeInScript,
|
||||
convertToKeyframesInScript,
|
||||
removeAllKeyframesFromScript,
|
||||
} from "./gsapParser.js";
|
||||
import type { GsapAnimation } from "./gsapParser.js";
|
||||
import type { Keyframe } from "../core.types";
|
||||
@@ -1185,3 +1190,315 @@ describe("fromTo in-place mutation", () => {
|
||||
expect(reparsed.animations[0].properties.scale).toBe(2.2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Native GSAP keyframes parsing ──────────────────────────────────────────
|
||||
|
||||
describe("native GSAP keyframes parsing", () => {
|
||||
it("parses percentage keyframes format", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: { "0%": { x: 0, opacity: 1 }, "50%": { x: 100, ease: "power2.out" }, "100%": { x: 200 } },
|
||||
duration: 5
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
const anim = result.animations[0];
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
expect(anim.keyframes!.format).toBe("percentage");
|
||||
expect(anim.keyframes!.keyframes).toHaveLength(3);
|
||||
|
||||
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1);
|
||||
|
||||
expect(anim.keyframes!.keyframes[1].percentage).toBe(50);
|
||||
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
|
||||
expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out");
|
||||
|
||||
expect(anim.keyframes!.keyframes[2].percentage).toBe(100);
|
||||
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
|
||||
});
|
||||
|
||||
it("parses object array keyframes format", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: [
|
||||
{ x: 0, opacity: 1, duration: 0.5 },
|
||||
{ x: 100, duration: 1, ease: "power2.out" },
|
||||
{ x: 200, duration: 0.8 }
|
||||
]
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
const anim = result.animations[0];
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
expect(anim.keyframes!.format).toBe("object-array");
|
||||
expect(anim.keyframes!.keyframes).toHaveLength(3);
|
||||
|
||||
// Total duration = 0.5 + 1 + 0.8 = 2.3
|
||||
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1);
|
||||
|
||||
// Second: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22
|
||||
expect(anim.keyframes!.keyframes[1].percentage).toBe(22);
|
||||
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
|
||||
expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out");
|
||||
|
||||
// Third: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65
|
||||
expect(anim.keyframes!.keyframes[2].percentage).toBe(65);
|
||||
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
|
||||
});
|
||||
|
||||
it("parses simple array keyframes format", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: { x: [0, 100, 200, 0], opacity: [0, 1, 1, 0], easeEach: "power2.inOut" },
|
||||
duration: 5
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
const anim = result.animations[0];
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
expect(anim.keyframes!.format).toBe("simple-array");
|
||||
expect(anim.keyframes!.easeEach).toBe("power2.inOut");
|
||||
expect(anim.keyframes!.keyframes).toHaveLength(4);
|
||||
|
||||
// Evenly spaced: 0%, 33%, 67%, 100%
|
||||
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(0);
|
||||
|
||||
expect(anim.keyframes!.keyframes[1].percentage).toBe(33);
|
||||
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
|
||||
expect(anim.keyframes!.keyframes[1].properties.opacity).toBe(1);
|
||||
|
||||
expect(anim.keyframes!.keyframes[2].percentage).toBe(67);
|
||||
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
|
||||
expect(anim.keyframes!.keyframes[2].properties.opacity).toBe(1);
|
||||
|
||||
expect(anim.keyframes!.keyframes[3].percentage).toBe(100);
|
||||
expect(anim.keyframes!.keyframes[3].properties.x).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[3].properties.opacity).toBe(0);
|
||||
});
|
||||
|
||||
it("parses three-level easing", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: { "0%": { x: 0 }, "50%": { x: 100, ease: "back.out(1.7)" }, "100%": { x: 200 } },
|
||||
ease: "none",
|
||||
easeEach: "power2.out",
|
||||
duration: 5
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
const anim = result.animations[0];
|
||||
|
||||
// Tween-level ease
|
||||
expect(anim.ease).toBe("none");
|
||||
// easeEach on keyframes data (set from tween-level)
|
||||
expect(anim.keyframes!.easeEach).toBe("power2.out");
|
||||
// Per-keyframe ease
|
||||
expect(anim.keyframes!.keyframes[1].ease).toBe("back.out(1.7)");
|
||||
});
|
||||
|
||||
it("flat tween without keyframes still works", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].keyframes).toBeUndefined();
|
||||
expect(result.animations[0].properties.x).toBe(100);
|
||||
});
|
||||
|
||||
it("keyframes tween has empty top-level properties", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: { "0%": { x: 0 }, "100%": { x: 200 } },
|
||||
duration: 5
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
const anim = result.animations[0];
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
expect(Object.keys(anim.properties)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Keyframe mutation functions ───────────────────────────────────────────
|
||||
|
||||
describe("keyframe mutations", () => {
|
||||
const KF_SCRIPT = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: { "0%": { x: 0, opacity: 0 }, "100%": { x: 200, opacity: 1 } },
|
||||
duration: 2
|
||||
}, 0);
|
||||
`;
|
||||
|
||||
const KF_SCRIPT_3 = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } },
|
||||
duration: 2
|
||||
}, 0);
|
||||
`;
|
||||
|
||||
function getAnimId(script: string): string {
|
||||
return parseGsapScript(script).animations[0].id;
|
||||
}
|
||||
|
||||
// ── addKeyframeToScript ─────────────────────────────────────────────────
|
||||
|
||||
it("addKeyframeToScript — inserts at sorted position", () => {
|
||||
const id = getAnimId(KF_SCRIPT);
|
||||
const updated = addKeyframeToScript(KF_SCRIPT, id, 50, { x: 100 });
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const kfs = reparsed.animations[0].keyframes!.keyframes;
|
||||
expect(kfs).toHaveLength(3);
|
||||
expect(kfs.map((k) => k.percentage)).toEqual([0, 50, 100]);
|
||||
expect(kfs[1].properties.x).toBe(100);
|
||||
});
|
||||
|
||||
it("addKeyframeToScript — updates existing percentage", () => {
|
||||
const id = getAnimId(KF_SCRIPT_3);
|
||||
const updated = addKeyframeToScript(KF_SCRIPT_3, id, 50, { x: 999 });
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const kfs = reparsed.animations[0].keyframes!.keyframes;
|
||||
expect(kfs).toHaveLength(3);
|
||||
expect(kfs[1].percentage).toBe(50);
|
||||
expect(kfs[1].properties.x).toBe(999);
|
||||
});
|
||||
|
||||
// ── removeKeyframeFromScript ────────────────────────────────────────────
|
||||
|
||||
it("removeKeyframeFromScript — removes one keyframe", () => {
|
||||
const id = getAnimId(KF_SCRIPT_3);
|
||||
const updated = removeKeyframeFromScript(KF_SCRIPT_3, id, 50);
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const kfs = reparsed.animations[0].keyframes!.keyframes;
|
||||
expect(kfs).toHaveLength(2);
|
||||
expect(kfs.map((k) => k.percentage)).toEqual([0, 100]);
|
||||
});
|
||||
|
||||
it("removeKeyframeFromScript — collapses to flat when <2 remain", () => {
|
||||
const id = getAnimId(KF_SCRIPT);
|
||||
const updated = removeKeyframeFromScript(KF_SCRIPT, id, 100);
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const anim = reparsed.animations[0];
|
||||
expect(anim.keyframes).toBeUndefined();
|
||||
expect(anim.properties.x).toBe(0);
|
||||
expect(anim.properties.opacity).toBe(0);
|
||||
});
|
||||
|
||||
// ── updateKeyframeInScript ──────────────────────────────────────────────
|
||||
|
||||
it("updateKeyframeInScript — replaces properties", () => {
|
||||
const id = getAnimId(KF_SCRIPT);
|
||||
const updated = updateKeyframeInScript(KF_SCRIPT, id, 100, { x: 300, y: 50 });
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const kf100 = reparsed.animations[0].keyframes!.keyframes.find((k) => k.percentage === 100)!;
|
||||
expect(kf100.properties.x).toBe(300);
|
||||
expect(kf100.properties.y).toBe(50);
|
||||
});
|
||||
|
||||
// ── convertToKeyframesInScript ──────────────────────────────────────────
|
||||
|
||||
it("convertToKeyframesInScript — converts flat to() tween", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#title", { x: 100, opacity: 1, duration: 0.8, ease: "power3.out" }, 0.3);
|
||||
`;
|
||||
const id = getAnimId(script);
|
||||
const updated = convertToKeyframesInScript(script, id, { x: 0, opacity: 0 });
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const anim = reparsed.animations[0];
|
||||
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
const kfs = anim.keyframes!.keyframes;
|
||||
expect(kfs).toHaveLength(2);
|
||||
|
||||
expect(kfs[0].percentage).toBe(0);
|
||||
expect(kfs[0].properties.x).toBe(0);
|
||||
expect(kfs[0].properties.opacity).toBe(0);
|
||||
|
||||
expect(kfs[1].percentage).toBe(100);
|
||||
expect(kfs[1].properties.x).toBe(100);
|
||||
expect(kfs[1].properties.opacity).toBe(1);
|
||||
|
||||
expect(anim.keyframes!.easeEach).toBe("power3.out");
|
||||
expect(anim.ease).toBe("none");
|
||||
expect(anim.duration).toBe(0.8);
|
||||
expect(anim.position).toBe(0.3);
|
||||
});
|
||||
|
||||
it("convertToKeyframesInScript — converts from() to to() + keyframes", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from("#title", { x: -200, opacity: 0, duration: 0.8 }, 0.3);
|
||||
`;
|
||||
const id = getAnimId(script);
|
||||
const updated = convertToKeyframesInScript(script, id, { x: 0, opacity: 1 });
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const anim = reparsed.animations[0];
|
||||
|
||||
expect(anim.method).toBe("to");
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
const kfs = anim.keyframes!.keyframes;
|
||||
expect(kfs[0].properties.x).toBe(-200);
|
||||
expect(kfs[0].properties.opacity).toBe(0);
|
||||
expect(kfs[1].properties.x).toBe(0);
|
||||
expect(kfs[1].properties.opacity).toBe(1);
|
||||
});
|
||||
|
||||
it("convertToKeyframesInScript — converts fromTo() to to() + keyframes", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#title", { x: -100 }, { x: 100, duration: 1 }, 0);
|
||||
`;
|
||||
const id = getAnimId(script);
|
||||
const updated = convertToKeyframesInScript(script, id);
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const anim = reparsed.animations[0];
|
||||
|
||||
expect(anim.method).toBe("to");
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
const kfs = anim.keyframes!.keyframes;
|
||||
expect(kfs[0].properties.x).toBe(-100);
|
||||
expect(kfs[1].properties.x).toBe(100);
|
||||
});
|
||||
|
||||
it("convertToKeyframesInScript — skips if already has keyframes", () => {
|
||||
const updated = convertToKeyframesInScript(KF_SCRIPT, getAnimId(KF_SCRIPT));
|
||||
expect(updated).toBe(KF_SCRIPT);
|
||||
});
|
||||
|
||||
// ── removeAllKeyframesFromScript ────────────────────────────────────────
|
||||
|
||||
it("removeAllKeyframesFromScript — collapses to last keyframe's props", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200, opacity: 1 } },
|
||||
duration: 2
|
||||
}, 0);
|
||||
`;
|
||||
const id = getAnimId(script);
|
||||
const updated = removeAllKeyframesFromScript(script, id);
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const anim = reparsed.animations[0];
|
||||
expect(anim.keyframes).toBeUndefined();
|
||||
expect(anim.properties.x).toBe(200);
|
||||
expect(anim.properties.opacity).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,22 @@
|
||||
*/
|
||||
import * as recast from "recast";
|
||||
import { parse as babelParse } from "@babel/parser";
|
||||
import { type GsapAnimation, type GsapMethod, type ParsedGsap } from "./gsapSerialize";
|
||||
import {
|
||||
type GsapAnimation,
|
||||
type GsapKeyframesData,
|
||||
type GsapMethod,
|
||||
type GsapPercentageKeyframe,
|
||||
type ParsedGsap,
|
||||
} from "./gsapSerialize";
|
||||
|
||||
export type { GsapAnimation, GsapMethod, ParsedGsap } from "./gsapSerialize";
|
||||
export type {
|
||||
GsapAnimation,
|
||||
GsapMethod,
|
||||
ParsedGsap,
|
||||
GsapKeyframesData,
|
||||
GsapPercentageKeyframe,
|
||||
GsapKeyframeFormat,
|
||||
} from "./gsapSerialize";
|
||||
export {
|
||||
serializeGsapAnimations,
|
||||
getAnimationsForElementId,
|
||||
@@ -441,7 +454,7 @@ function findAllTweenCalls(
|
||||
const BUILTIN_VAR_KEYS = new Set(["duration", "ease", "delay"]);
|
||||
|
||||
/** Keys that are never preserved (callbacks / advanced patterns). */
|
||||
const DROPPED_VAR_KEYS = new Set(["keyframes", "onComplete", "onStart", "onUpdate", "onRepeat"]);
|
||||
const DROPPED_VAR_KEYS = new Set(["onComplete", "onStart", "onUpdate", "onRepeat"]);
|
||||
|
||||
/** Keys that belong in `extras` — non-editable GSAP config that must survive round-trips. */
|
||||
const EXTRAS_KEYS = new Set([
|
||||
@@ -459,17 +472,221 @@ const EXTRAS_KEYS = new Set([
|
||||
* Returns the printed source of the value node, suitable for verbatim re-emission.
|
||||
*/
|
||||
function extractRawPropertySource(varsArgNode: any, key: string): string | undefined {
|
||||
const node = findPropertyNode(varsArgNode, key);
|
||||
return node ? recast.print(node).code : undefined;
|
||||
}
|
||||
|
||||
/** Find the raw AST node for a named property inside an ObjectExpression. */
|
||||
function findPropertyNode(varsArgNode: any, key: string): any | undefined {
|
||||
if (varsArgNode?.type !== "ObjectExpression") return undefined;
|
||||
for (const prop of varsArgNode.properties ?? []) {
|
||||
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
|
||||
const propKey = prop.key?.name ?? prop.key?.value;
|
||||
if (propKey === key) {
|
||||
return recast.print(prop.value).code;
|
||||
}
|
||||
if (!isObjectProperty(prop)) continue;
|
||||
if (propKeyName(prop) === key) return prop.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Native GSAP Keyframes Parsing ──────────────────────────────────────────
|
||||
|
||||
const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
|
||||
|
||||
/** Extract a string-valued ease or easeEach from an AST property node. */
|
||||
function tryResolveStringProp(propValue: any, scope: ScopeBindings): string | undefined {
|
||||
const val = resolveNode(propValue, scope);
|
||||
return typeof val === "string" ? val : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `keyframes` property value from a tween vars AST node into a
|
||||
* normalized `GsapKeyframesData` structure. Handles all three GSAP formats:
|
||||
* percentage objects, object arrays, and simple (property-array) objects.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
function parseKeyframesNode(node: any, scope: ScopeBindings): GsapKeyframesData | undefined {
|
||||
if (!node) return undefined;
|
||||
|
||||
// ── Object array format: keyframes: [ { x: 0, duration: 0.5 }, ... ] ──
|
||||
if (node.type === "ArrayExpression") {
|
||||
return parseObjectArrayKeyframes(node, scope);
|
||||
}
|
||||
|
||||
if (node.type !== "ObjectExpression") return undefined;
|
||||
|
||||
// Distinguish percentage vs simple-array by inspecting property keys/values.
|
||||
const props = node.properties ?? [];
|
||||
let hasPercentageKey = false;
|
||||
let hasArrayValue = false;
|
||||
|
||||
for (const prop of props) {
|
||||
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
|
||||
const key = prop.key?.value ?? prop.key?.name;
|
||||
if (typeof key === "string" && PERCENTAGE_KEY_RE.test(key)) {
|
||||
hasPercentageKey = true;
|
||||
break;
|
||||
}
|
||||
if (prop.value?.type === "ArrayExpression") {
|
||||
hasArrayValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPercentageKey) return parsePercentageKeyframes(node, scope);
|
||||
if (hasArrayValue) return parseSimpleArrayKeyframes(node, scope);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parsePercentageKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
|
||||
const keyframes: GsapPercentageKeyframe[] = [];
|
||||
let ease: string | undefined;
|
||||
let easeEach: string | undefined;
|
||||
|
||||
for (const prop of node.properties ?? []) {
|
||||
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
|
||||
const key = prop.key?.value ?? prop.key?.name;
|
||||
if (typeof key !== "string") continue;
|
||||
|
||||
const pctMatch = PERCENTAGE_KEY_RE.exec(key);
|
||||
if (pctMatch) {
|
||||
const percentage = Number.parseFloat(pctMatch[1]!);
|
||||
const record = objectExpressionToRecord(prop.value, scope);
|
||||
const properties: Record<string, number | string> = {};
|
||||
let kfEase: string | undefined;
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
if (k === "ease" && typeof v === "string") {
|
||||
kfEase = v;
|
||||
} else if (typeof v === "number" || typeof v === "string") {
|
||||
properties[k] = v;
|
||||
}
|
||||
}
|
||||
keyframes.push({ percentage, properties, ...(kfEase ? { ease: kfEase } : {}) });
|
||||
} else if (key === "ease") {
|
||||
ease = tryResolveStringProp(prop.value, scope) ?? ease;
|
||||
} else if (key === "easeEach") {
|
||||
easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach;
|
||||
}
|
||||
}
|
||||
|
||||
keyframes.sort((a, b) => a.percentage - b.percentage);
|
||||
|
||||
return {
|
||||
format: "percentage",
|
||||
keyframes,
|
||||
...(ease ? { ease } : {}),
|
||||
...(easeEach ? { easeEach } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
|
||||
const elements = node.elements ?? [];
|
||||
const raw: Array<{
|
||||
properties: Record<string, number | string>;
|
||||
duration?: number;
|
||||
ease?: string;
|
||||
}> = [];
|
||||
|
||||
for (const el of elements) {
|
||||
if (!el || (el.type !== "ObjectExpression" && el.type !== "ObjectProperty")) {
|
||||
// Skip non-object elements
|
||||
if (el?.type !== "ObjectExpression") continue;
|
||||
}
|
||||
const record = objectExpressionToRecord(el, scope);
|
||||
const properties: Record<string, number | string> = {};
|
||||
let duration: number | undefined;
|
||||
let ease: string | undefined;
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
if (k === "duration" && typeof v === "number") {
|
||||
duration = v;
|
||||
} else if (k === "ease" && typeof v === "string") {
|
||||
ease = v;
|
||||
} else if (typeof v === "number" || typeof v === "string") {
|
||||
properties[k] = v;
|
||||
}
|
||||
}
|
||||
raw.push({ properties, duration, ease });
|
||||
}
|
||||
|
||||
// Convert durations to percentage positions. If durations are present, use
|
||||
// cumulative ratios; otherwise distribute evenly.
|
||||
const totalDuration = raw.reduce((sum, r) => sum + (r.duration ?? 0), 0);
|
||||
const keyframes: GsapPercentageKeyframe[] = [];
|
||||
|
||||
if (totalDuration > 0) {
|
||||
let cumulative = 0;
|
||||
for (const entry of raw) {
|
||||
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++) {
|
||||
const entry = raw[i]!;
|
||||
const percentage = raw.length > 1 ? Math.round((i / (raw.length - 1)) * 100) : 0;
|
||||
keyframes.push({
|
||||
percentage,
|
||||
properties: entry.properties,
|
||||
...(entry.ease ? { ease: entry.ease } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { format: "object-array", keyframes };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parseSimpleArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
|
||||
const arrayProps: Map<string, (number | string)[]> = new Map();
|
||||
let ease: string | undefined;
|
||||
let easeEach: string | undefined;
|
||||
|
||||
for (const prop of node.properties ?? []) {
|
||||
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
|
||||
const key = prop.key?.name ?? prop.key?.value;
|
||||
if (typeof key !== "string") continue;
|
||||
|
||||
if (prop.value?.type === "ArrayExpression") {
|
||||
const values: (number | string)[] = [];
|
||||
for (const el of prop.value.elements ?? []) {
|
||||
const val = resolveNode(el, scope);
|
||||
if (typeof val === "number" || typeof val === "string") {
|
||||
values.push(val);
|
||||
}
|
||||
}
|
||||
if (values.length > 0) arrayProps.set(key, values);
|
||||
} else if (key === "ease") {
|
||||
ease = tryResolveStringProp(prop.value, scope) ?? ease;
|
||||
} else if (key === "easeEach") {
|
||||
easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach;
|
||||
}
|
||||
}
|
||||
|
||||
// Zip arrays into percentage keyframes (evenly spaced).
|
||||
const maxLen = Math.max(...[...arrayProps.values()].map((a) => a.length), 0);
|
||||
const keyframes: GsapPercentageKeyframe[] = [];
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const percentage = maxLen > 1 ? Math.round((i / (maxLen - 1)) * 100) : 0;
|
||||
const properties: Record<string, number | string> = {};
|
||||
for (const [key, values] of arrayProps) {
|
||||
if (i < values.length) properties[key] = values[i]!;
|
||||
}
|
||||
keyframes.push({ percentage, properties });
|
||||
}
|
||||
|
||||
return {
|
||||
format: "simple-array",
|
||||
keyframes,
|
||||
...(ease ? { ease } : {}),
|
||||
...(easeEach ? { easeEach } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function tweenCallToAnimation(
|
||||
call: TweenCallInfo,
|
||||
scope: ScopeBindings,
|
||||
@@ -477,11 +694,23 @@ function tweenCallToAnimation(
|
||||
const vars = objectExpressionToRecord(call.varsArg, scope);
|
||||
const properties: Record<string, number | string> = {};
|
||||
const extras: Record<string, unknown> = {};
|
||||
let keyframesData: GsapKeyframesData | undefined;
|
||||
|
||||
for (const [key, val] of Object.entries(vars)) {
|
||||
if (BUILTIN_VAR_KEYS.has(key)) continue;
|
||||
if (DROPPED_VAR_KEYS.has(key)) continue;
|
||||
|
||||
if (key === "keyframes") {
|
||||
const kfNode = findPropertyNode(call.varsArg, "keyframes");
|
||||
keyframesData = parseKeyframesNode(kfNode, scope);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "easeEach") {
|
||||
// easeEach is only meaningful alongside keyframes — handled below.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (EXTRAS_KEYS.has(key)) {
|
||||
// For extras, prefer the raw AST source so complex objects like
|
||||
// `stagger: { each: 0.15, from: "start" }` survive verbatim.
|
||||
@@ -499,6 +728,11 @@ function tweenCallToAnimation(
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tween-level easeEach to keyframes data.
|
||||
if (keyframesData && typeof vars.easeEach === "string") {
|
||||
keyframesData.easeEach = vars.easeEach as string;
|
||||
}
|
||||
|
||||
let fromProperties: Record<string, number | string> | undefined;
|
||||
if (call.method === "fromTo" && call.fromArg) {
|
||||
fromProperties = {};
|
||||
@@ -526,6 +760,7 @@ function tweenCallToAnimation(
|
||||
ease,
|
||||
};
|
||||
if (Object.keys(extras).length > 0) anim.extras = extras;
|
||||
if (keyframesData) anim.keyframes = keyframesData;
|
||||
return anim;
|
||||
}
|
||||
|
||||
@@ -859,3 +1094,307 @@ export function removeAnimationFromScript(script: string, animationId: string):
|
||||
}
|
||||
return recast.print(parsed.ast).code;
|
||||
}
|
||||
|
||||
// ── Keyframe Mutation Functions ────────────────────────────────────────────
|
||||
|
||||
/** Remove a named property from an ObjectExpression's properties array. */
|
||||
function removeVarsKey(varsArg: any, key: string): void {
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
varsArg.properties = varsArg.properties.filter(
|
||||
(p: any) => !(isObjectProperty(p) && propKeyName(p) === key),
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract the numeric percentage from a key like "50%". Returns NaN for non-percentage keys. */
|
||||
function percentageFromKey(key: string): number {
|
||||
const m = PERCENTAGE_KEY_RE.exec(key);
|
||||
return m ? Number.parseFloat(m[1]!) : Number.NaN;
|
||||
}
|
||||
|
||||
/** 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)}`);
|
||||
if (ease) entries.push(`ease: ${JSON.stringify(ease)}`);
|
||||
return parseExpr(`{ ${entries.join(", ")} }`);
|
||||
}
|
||||
|
||||
/** Parse + locate a target animation, returning null on failure. */
|
||||
function locateAnimation(
|
||||
script: string,
|
||||
animationId: string,
|
||||
): { parsed: ParsedGsapAst; target: ParsedGsapAst["located"][number] } | null {
|
||||
let parsed: ParsedGsapAst;
|
||||
try {
|
||||
parsed = parseGsapAst(script);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
return target ? { parsed, target } : null;
|
||||
}
|
||||
|
||||
/** Find the keyframes ObjectExpression node on a tween's varsArg, or null. */
|
||||
function findKeyframesObjectNode(varsArg: any): any | null {
|
||||
const node = findPropertyNode(varsArg, "keyframes");
|
||||
return node?.type === "ObjectExpression" ? node : null;
|
||||
}
|
||||
|
||||
/** Filter percentage-keyed properties from a keyframes ObjectExpression. */
|
||||
function filterPercentageProps(kfNode: any): any[] {
|
||||
return kfNode.properties.filter((p: any) => {
|
||||
if (!isObjectProperty(p)) return false;
|
||||
const key = propKeyName(p);
|
||||
return typeof key === "string" && PERCENTAGE_KEY_RE.test(key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a keyframes node to flat tween: apply `record` entries as vars keys,
|
||||
* then remove `keyframes` and `easeEach` from varsArg. Skips the `ease` key
|
||||
* from the record (per-keyframe ease, not a tween ease).
|
||||
*/
|
||||
function collapseKeyframesToFlat(varsArg: any, record: Record<string, unknown>): void {
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
if (k === "ease") continue;
|
||||
if (typeof v === "number" || typeof v === "string") setVarsKey(varsArg, k, v);
|
||||
}
|
||||
removeVarsKey(varsArg, "keyframes");
|
||||
removeVarsKey(varsArg, "easeEach");
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a keyframe at the given percentage in an existing percentage-keyframes
|
||||
* object. If the percentage already exists, its value is replaced.
|
||||
*/
|
||||
export function addKeyframeToScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
ease?: string,
|
||||
): string {
|
||||
const loc = locateAnimation(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
const pctKey = `${percentage}%`;
|
||||
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;
|
||||
return recast.print(loc.parsed.ast).code;
|
||||
}
|
||||
|
||||
// Build the new property node with a quoted percentage key
|
||||
const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0];
|
||||
newProp.value = newValueNode;
|
||||
|
||||
// Insert in sorted order by percentage
|
||||
let insertIdx = kfNode.properties.length;
|
||||
for (let i = 0; i < kfNode.properties.length; i++) {
|
||||
const key = isObjectProperty(kfNode.properties[i])
|
||||
? propKeyName(kfNode.properties[i])
|
||||
: undefined;
|
||||
if (typeof key === "string" && percentageFromKey(key) > percentage) {
|
||||
insertIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
kfNode.properties.splice(insertIdx, 0, newProp);
|
||||
return recast.print(loc.parsed.ast).code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a keyframe at the given percentage. If fewer than 2 keyframes remain
|
||||
* after removal, collapse the keyframes object to a flat tween using the
|
||||
* remaining keyframe's properties.
|
||||
*/
|
||||
export function removeKeyframeFromScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
): string {
|
||||
const loc = locateAnimation(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
const pctKey = `${percentage}%`;
|
||||
const removeIdx = kfNode.properties.findIndex(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
|
||||
);
|
||||
if (removeIdx === -1) return script;
|
||||
|
||||
kfNode.properties.splice(removeIdx, 1);
|
||||
|
||||
const remainingKfs = filterPercentageProps(kfNode);
|
||||
if (remainingKfs.length < 2) {
|
||||
const record =
|
||||
remainingKfs.length === 1
|
||||
? objectExpressionToRecord(remainingKfs[0].value, loc.parsed.scope)
|
||||
: {};
|
||||
collapseKeyframesToFlat(loc.target.call.varsArg, record);
|
||||
}
|
||||
|
||||
return recast.print(loc.parsed.ast).code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the properties (and optionally ease) at an existing keyframe percentage.
|
||||
*/
|
||||
export function updateKeyframeInScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
ease?: string,
|
||||
): string {
|
||||
const loc = locateAnimation(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
const pctKey = `${percentage}%`;
|
||||
const existing = kfNode.properties.find(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
|
||||
);
|
||||
if (!existing) return script;
|
||||
|
||||
existing.value = buildKeyframeValueNode(properties, ease);
|
||||
return recast.print(loc.parsed.ast).code;
|
||||
}
|
||||
|
||||
/** Resolve from/to property maps for a tween being converted to keyframes. */
|
||||
const CSS_IDENTITY: Record<string, number> = {
|
||||
opacity: 1,
|
||||
autoAlpha: 1,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
};
|
||||
|
||||
function cssIdentityValue(prop: string): number {
|
||||
return CSS_IDENTITY[prop] ?? 0;
|
||||
}
|
||||
|
||||
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 } };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
// fromTo
|
||||
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps: { ...anim.properties } };
|
||||
}
|
||||
|
||||
/** Strip editable properties and ease/keyframes keys from a varsArg. */
|
||||
function stripEditableAndEase(varsArg: any): void {
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
varsArg.properties = varsArg.properties.filter((p: any) => {
|
||||
if (!isObjectProperty(p)) return true;
|
||||
const key = propKeyName(p);
|
||||
if (typeof key !== "string") return true;
|
||||
if (key === "ease" || key === "keyframes") return false;
|
||||
return !isEditablePropertyKey(key);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build and prepend a keyframes property node onto varsArg. */
|
||||
function insertKeyframesProp(
|
||||
varsArg: any,
|
||||
fromProps: Record<string, number | string>,
|
||||
toProps: Record<string, number | string>,
|
||||
): void {
|
||||
const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} } }`;
|
||||
const kfProp = parseExpr(`{ keyframes: {} }`).properties[0];
|
||||
kfProp.value = parseExpr(kfCode);
|
||||
if (varsArg?.type === "ObjectExpression") varsArg.properties.unshift(kfProp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a flat tween (to/from/fromTo) to percentage-keyframes format.
|
||||
* `resolvedFromValues` supplies the "from" state for `to()` tweens or
|
||||
* the "to" state for `from()` tweens (the values the DOM would resolve to).
|
||||
*/
|
||||
export function convertToKeyframesInScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
): string {
|
||||
const loc = locateAnimation(script, animationId);
|
||||
if (!loc) return script;
|
||||
|
||||
const anim = loc.target.animation;
|
||||
if (anim.keyframes || anim.method === "set") return script;
|
||||
|
||||
const { fromProps, toProps } = resolveConversionProps(anim, resolvedFromValues);
|
||||
const varsArg = loc.target.call.varsArg;
|
||||
const originalEase = anim.ease;
|
||||
|
||||
stripEditableAndEase(varsArg);
|
||||
insertKeyframesProp(varsArg, fromProps, toProps);
|
||||
|
||||
if (originalEase) {
|
||||
setVarsKey(varsArg, "easeEach", originalEase);
|
||||
setVarsKey(varsArg, "ease", "none");
|
||||
}
|
||||
|
||||
// For from() or fromTo(), convert to to()
|
||||
if (anim.method === "from" || anim.method === "fromTo") {
|
||||
loc.target.call.node.callee.property.name = "to";
|
||||
if (anim.method === "fromTo") loc.target.call.node.arguments.splice(1, 1);
|
||||
}
|
||||
|
||||
return recast.print(loc.parsed.ast).code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all keyframes from a tween, collapsing to a flat tween with the
|
||||
* last keyframe's properties.
|
||||
*/
|
||||
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
|
||||
const loc = locateAnimation(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
// Collect all percentage keyframe entries, sorted
|
||||
const kfEntries = filterPercentageProps(kfNode)
|
||||
.map((p: any) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
|
||||
.filter((e) => !Number.isNaN(e.pct))
|
||||
.sort((a, b) => a.pct - b.pct);
|
||||
if (kfEntries.length === 0) return script;
|
||||
|
||||
const lastRecord = objectExpressionToRecord(
|
||||
kfEntries[kfEntries.length - 1]!.prop.value,
|
||||
loc.parsed.scope,
|
||||
);
|
||||
collapseKeyframesToFlat(loc.target.call.varsArg, lastRecord);
|
||||
|
||||
return recast.print(loc.parsed.ast).code;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,23 @@ export interface GsapAnimation {
|
||||
ease?: string;
|
||||
/** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */
|
||||
extras?: Record<string, unknown>;
|
||||
/** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */
|
||||
keyframes?: GsapKeyframesData;
|
||||
}
|
||||
|
||||
export interface GsapPercentageKeyframe {
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}
|
||||
|
||||
export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array";
|
||||
|
||||
export interface GsapKeyframesData {
|
||||
format: GsapKeyframeFormat;
|
||||
keyframes: GsapPercentageKeyframe[];
|
||||
ease?: string;
|
||||
easeEach?: string;
|
||||
}
|
||||
|
||||
export interface ParsedGsap {
|
||||
|
||||
@@ -579,7 +579,28 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
defaultValue: number | string;
|
||||
}
|
||||
| { type: "remove-property"; animationId: string; property: string }
|
||||
| { type: "remove-from-property"; animationId: string; property: string };
|
||||
| { type: "remove-from-property"; animationId: string; property: string }
|
||||
| {
|
||||
type: "add-keyframe";
|
||||
animationId: string;
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}
|
||||
| { type: "remove-keyframe"; animationId: string; percentage: number }
|
||||
| {
|
||||
type: "update-keyframe";
|
||||
animationId: string;
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}
|
||||
| {
|
||||
type: "convert-to-keyframes";
|
||||
animationId: string;
|
||||
resolvedFromValues?: Record<string, number | string>;
|
||||
}
|
||||
| { type: "remove-all-keyframes"; animationId: string };
|
||||
|
||||
api.post("/projects/:id/gsap-mutations/*", async (c) => {
|
||||
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
|
||||
@@ -706,6 +727,47 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "add-keyframe": {
|
||||
const { addKeyframeToScript } = await loadGsapParser();
|
||||
newScript = addKeyframeToScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.percentage,
|
||||
body.properties,
|
||||
body.ease,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "remove-keyframe": {
|
||||
const { removeKeyframeFromScript } = await loadGsapParser();
|
||||
newScript = removeKeyframeFromScript(block.scriptText, body.animationId, body.percentage);
|
||||
break;
|
||||
}
|
||||
case "update-keyframe": {
|
||||
const { updateKeyframeInScript } = await loadGsapParser();
|
||||
newScript = updateKeyframeInScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.percentage,
|
||||
body.properties,
|
||||
body.ease,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "convert-to-keyframes": {
|
||||
const { convertToKeyframesInScript } = await loadGsapParser();
|
||||
newScript = convertToKeyframesInScript(
|
||||
block.scriptText,
|
||||
body.animationId,
|
||||
body.resolvedFromValues,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "remove-all-keyframes": {
|
||||
const { removeAllKeyframesFromScript } = await loadGsapParser();
|
||||
newScript = removeAllKeyframesFromScript(block.scriptText, body.animationId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user