Skip to content

Commit 7e1a098

Browse files
committed
fix(studio): address Vai+Rames follow-up notes on hf#1122
- extract buildTweenSummary to gsapAnimationHelpers.ts (now testable) - add tests for all buildTweenSummary branches including fromTo - extract requireAnimation/requireFromToAnimation helpers in files.ts, eliminating the parse→find→guard pattern repeated across three switch cases and removing the fallow-ignore-next-line complexity bypass - add 400 guard: add mutation with fromProperties on non-fromTo method now returns 400 instead of silently dropping fromProperties - add test for the 400 guard
1 parent 7cc74ee commit 7e1a098

5 files changed

Lines changed: 171 additions & 57 deletions

File tree

‎packages/core/src/studio-api/routes/files.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,34 @@ const tl = gsap.timeline();
327327
expect(anim.properties.opacity).toBe(1);
328328
});
329329

330+
it("add mutation returns 400 when fromProperties provided for non-fromTo method", async () => {
331+
const projectDir = createProjectDir();
332+
const EMPTY_COMP = `<!DOCTYPE html><html><body><div id="el"></div><script data-hyperframes-gsap>
333+
const tl = gsap.timeline();
334+
</script></body></html>`;
335+
writeHtml(projectDir, "empty.html", EMPTY_COMP);
336+
const app = new Hono();
337+
registerFileRoutes(app, createAdapter(projectDir));
338+
339+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/empty.html", {
340+
method: "POST",
341+
headers: { "Content-Type": "application/json" },
342+
body: JSON.stringify({
343+
type: "add",
344+
targetSelector: "#el",
345+
method: "to",
346+
position: 0,
347+
duration: 0.5,
348+
ease: "power2.out",
349+
properties: { opacity: 1 },
350+
fromProperties: { opacity: 0 },
351+
}),
352+
});
353+
expect(res.status).toBe(400);
354+
const body = (await res.json()) as { error: string };
355+
expect(body.error).toContain("fromProperties");
356+
});
357+
330358
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
331359
const projectDir = createProjectDir();
332360
writeComp(projectDir, "scene.html", TEMPLATE_COMP);

‎packages/core/src/studio-api/routes/files.ts‎

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { isAudioFile } from "../helpers/mime.js";
1717
import { generateWaveformCache } from "../helpers/waveform.js";
1818
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
1919
import { isSafePath } from "../helpers/safePath.js";
20+
import type { GsapAnimation } from "../../parsers/gsapSerialize.js";
2021
import {
2122
removeElementFromHtml,
2223
patchElementInHtml,
@@ -600,26 +601,44 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
600601
removeAnimationFromScript,
601602
} = await loadGsapParser();
602603

604+
function requireAnimation(
605+
scriptText: string,
606+
animationId: string,
607+
): { anim: GsapAnimation } | { err: Response } {
608+
const parsed = parseGsapScript(scriptText);
609+
const anim = parsed.animations.find((a) => a.id === animationId);
610+
if (!anim) return { err: c.json({ error: "animation not found" }, 404) };
611+
return { anim };
612+
}
613+
614+
function requireFromToAnimation(
615+
scriptText: string,
616+
animationId: string,
617+
): { anim: GsapAnimation } | { err: Response } {
618+
const result = requireAnimation(scriptText, animationId);
619+
if ("err" in result) return result;
620+
if (result.anim.method !== "fromTo")
621+
return { err: c.json({ error: "animation is not a fromTo" }, 400) };
622+
return result;
623+
}
624+
603625
let newScript: string;
604626

605627
// fallow-ignore-next-line complexity
606628
switch (body.type) {
607629
case "update-property": {
608-
const parsed = parseGsapScript(block.scriptText);
609-
const anim = parsed.animations.find((a) => a.id === body.animationId);
610-
if (!anim) return c.json({ error: "animation not found" }, 404);
630+
const r = requireAnimation(block.scriptText, body.animationId);
631+
if ("err" in r) return r.err;
611632
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
612-
properties: { ...anim.properties, [body.property]: body.value },
633+
properties: { ...r.anim.properties, [body.property]: body.value },
613634
});
614635
break;
615636
}
616637
case "update-from-property": {
617-
const parsed = parseGsapScript(block.scriptText);
618-
const anim = parsed.animations.find((a) => a.id === body.animationId);
619-
if (!anim) return c.json({ error: "animation not found" }, 404);
620-
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
638+
const r = requireFromToAnimation(block.scriptText, body.animationId);
639+
if ("err" in r) return r.err;
621640
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
622-
fromProperties: { ...(anim.fromProperties ?? {}), [body.property]: body.value },
641+
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.value },
623642
});
624643
break;
625644
}
@@ -628,6 +647,9 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
628647
break;
629648
}
630649
case "add": {
650+
if (body.fromProperties && body.method !== "fromTo") {
651+
return c.json({ error: "fromProperties is only valid for method=fromTo" }, 400);
652+
}
631653
const result = addAnimationToScript(block.scriptText, {
632654
targetSelector: body.targetSelector,
633655
method: body.method,
@@ -645,41 +667,35 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
645667
break;
646668
}
647669
case "add-property": {
648-
const parsed = parseGsapScript(block.scriptText);
649-
const anim = parsed.animations.find((a) => a.id === body.animationId);
650-
if (!anim) return c.json({ error: "animation not found" }, 404);
670+
const r = requireAnimation(block.scriptText, body.animationId);
671+
if ("err" in r) return r.err;
651672
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
652-
properties: { ...anim.properties, [body.property]: body.defaultValue },
673+
properties: { ...r.anim.properties, [body.property]: body.defaultValue },
653674
});
654675
break;
655676
}
656677
case "add-from-property": {
657-
const parsed = parseGsapScript(block.scriptText);
658-
const anim = parsed.animations.find((a) => a.id === body.animationId);
659-
if (!anim) return c.json({ error: "animation not found" }, 404);
660-
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
678+
const r = requireFromToAnimation(block.scriptText, body.animationId);
679+
if ("err" in r) return r.err;
661680
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
662-
fromProperties: { ...(anim.fromProperties ?? {}), [body.property]: body.defaultValue },
681+
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.defaultValue },
663682
});
664683
break;
665684
}
666685
case "remove-property": {
667-
const parsed = parseGsapScript(block.scriptText);
668-
const anim = parsed.animations.find((a) => a.id === body.animationId);
669-
if (!anim) return c.json({ error: "animation not found" }, 404);
670-
const filtered = { ...anim.properties };
686+
const r = requireAnimation(block.scriptText, body.animationId);
687+
if ("err" in r) return r.err;
688+
const filtered = { ...r.anim.properties };
671689
delete filtered[body.property];
672690
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
673691
properties: filtered,
674692
});
675693
break;
676694
}
677695
case "remove-from-property": {
678-
const parsed = parseGsapScript(block.scriptText);
679-
const anim = parsed.animations.find((a) => a.id === body.animationId);
680-
if (!anim) return c.json({ error: "animation not found" }, 404);
681-
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
682-
const filtered = { ...(anim.fromProperties ?? {}) };
696+
const r = requireFromToAnimation(block.scriptText, body.animationId);
697+
if ("err" in r) return r.err;
698+
const filtered = { ...(r.anim.fromProperties ?? {}) };
683699
delete filtered[body.property];
684700
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
685701
fromProperties: filtered,

‎packages/studio/src/components/editor/AnimationCard.tsx‎

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
PROP_TOOLTIPS,
1313
PROP_UNITS,
1414
} from "./gsapAnimationConstants";
15+
import { buildTweenSummary } from "./gsapAnimationHelpers";
1516
import { EaseCurveSection } from "./EaseCurveSection";
1617

1718
const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
@@ -158,36 +159,6 @@ function AddPropertyTrigger({
158159
);
159160
}
160161

161-
// fallow-ignore-next-line complexity
162-
function buildTweenSummary(animation: GsapAnimation): string {
163-
const easeName = animation.ease ?? "none";
164-
const ease = EASE_LABELS[easeName] ?? easeName;
165-
const props = Object.entries(animation.properties);
166-
const target = animation.targetSelector;
167-
const dur = animation.duration ?? 0;
168-
const pos = animation.position;
169-
const propDescs = props.map(([p, v]) => {
170-
const label = (PROP_LABELS[p] ?? p).toLowerCase();
171-
const unit = PROP_UNITS[p] ?? "";
172-
return `${label} to ${v}${unit}`;
173-
});
174-
const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet";
175-
if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`;
176-
if (animation.method === "from")
177-
return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`;
178-
if (animation.method === "fromTo") {
179-
const fromProps = Object.entries(animation.fromProperties ?? {});
180-
const fromDescs = fromProps.map(([p, v]) => {
181-
const label = (PROP_LABELS[p] ?? p).toLowerCase();
182-
const unit = PROP_UNITS[p] ?? "";
183-
return `${label} ${v}${unit}`;
184-
});
185-
const fromText = fromDescs.length > 0 ? fromDescs.join(", ") : "—";
186-
return `Starting at ${pos}s, over ${dur}s, ${target} animates from [${fromText}] to [${propText}] using a ${ease.toLowerCase()} curve.`;
187-
}
188-
return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
189-
}
190-
191162
function parseNumericOrString(raw: string): number | string {
192163
const num = Number(raw);
193164
return Number.isFinite(num) ? num : raw;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildTweenSummary } from "./gsapAnimationHelpers";
3+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4+
5+
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
6+
return {
7+
id: "a1",
8+
method: "to",
9+
targetSelector: "#box",
10+
properties: {},
11+
position: 0,
12+
duration: 1,
13+
ease: "power2.out",
14+
...overrides,
15+
} as GsapAnimation;
16+
}
17+
18+
describe("buildTweenSummary", () => {
19+
it("describes a to tween", () => {
20+
const s = buildTweenSummary(anim({ properties: { opacity: 1, x: 100 } }));
21+
expect(s).toContain("#box");
22+
expect(s).toContain("opacity");
23+
expect(s).toContain("move x");
24+
});
25+
26+
it("describes a from tween", () => {
27+
const s = buildTweenSummary(anim({ method: "from", properties: { opacity: 0 } }));
28+
expect(s).toContain("enters from");
29+
expect(s).toContain("opacity");
30+
});
31+
32+
it("describes a set tween", () => {
33+
const s = buildTweenSummary(anim({ method: "set", properties: { opacity: 0 } }));
34+
expect(s).toMatch(/^At 0s, instantly set/);
35+
expect(s).toContain("opacity");
36+
});
37+
38+
it("describes a fromTo tween with both from and to sections", () => {
39+
const s = buildTweenSummary(
40+
anim({
41+
method: "fromTo",
42+
fromProperties: { opacity: 0, x: -50 },
43+
properties: { opacity: 1, x: 0 },
44+
position: 0.5,
45+
duration: 1.5,
46+
ease: "expo.out",
47+
}),
48+
);
49+
expect(s).toContain("animates from");
50+
expect(s).toContain("[opacity 0%");
51+
expect(s).toContain("move x -50px");
52+
expect(s).toContain("opacity to 100%");
53+
expect(s).toContain("very snappy stop");
54+
});
55+
56+
it("handles fromTo with empty fromProperties", () => {
57+
const s = buildTweenSummary(
58+
anim({ method: "fromTo", fromProperties: {}, properties: { scale: 2 } }),
59+
);
60+
expect(s).toContain("from [—]");
61+
});
62+
63+
it("handles no properties", () => {
64+
const s = buildTweenSummary(anim({ properties: {} }));
65+
expect(s).toContain("no properties yet");
66+
});
67+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2+
import { EASE_LABELS, PROP_LABELS, PROP_UNITS } from "./gsapAnimationConstants";
3+
4+
// fallow-ignore-next-line complexity
5+
export function buildTweenSummary(animation: GsapAnimation): string {
6+
const easeName = animation.ease ?? "none";
7+
const ease = EASE_LABELS[easeName] ?? easeName;
8+
const props = Object.entries(animation.properties);
9+
const target = animation.targetSelector;
10+
const dur = animation.duration ?? 0;
11+
const pos = animation.position;
12+
const propDescs = props.map(([p, v]) => {
13+
const label = (PROP_LABELS[p] ?? p).toLowerCase();
14+
const unit = PROP_UNITS[p] ?? "";
15+
return `${label} to ${v}${unit}`;
16+
});
17+
const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet";
18+
if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`;
19+
if (animation.method === "from")
20+
return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`;
21+
if (animation.method === "fromTo") {
22+
const fromProps = Object.entries(animation.fromProperties ?? {});
23+
const fromDescs = fromProps.map(([p, v]) => {
24+
const label = (PROP_LABELS[p] ?? p).toLowerCase();
25+
const unit = PROP_UNITS[p] ?? "";
26+
return `${label} ${v}${unit}`;
27+
});
28+
const fromText = fromDescs.length > 0 ? fromDescs.join(", ") : "—";
29+
return `Starting at ${pos}s, over ${dur}s, ${target} animates from [${fromText}] to [${propText}] using a ${ease.toLowerCase()} curve.`;
30+
}
31+
return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
32+
}

0 commit comments

Comments
 (0)