Skip to content

Commit 6a0c9a5

Browse files
fix(studio): surface fromTo from-state in GSAP design panel (#1122)
* fix(studio): surface fromTo from-state in GSAP design panel Closes #1121. The core already parsed, serialized, and mutated fromProperties end to end (gsapParser.ts, applyUpdatesToCall, buildTweenStatementCode). The panel never wired it in — AnimationCard only read animation.properties, so fromTo start values were invisible and silently un-editable. Changes: - files.ts: add update-from-property / add-from-property / remove-from-property mutation types; pass fromProperties through the add case; add fromTo to the method union - useGsapScriptCommits: updateGsapFromProperty, addGsapFromProperty, removeGsapFromProperty; addGsapAnimation extended to fromTo with { opacity:0 } → { opacity:1 } defaults - gsapAnimationConstants: fromTo added to ADD_METHODS / ADD_METHOD_LABELS ("From → To") so it can be authored from the panel - AnimationCard: From section with per-row edit/remove and + From property picker (orange accent to distinguish from To section); buildTweenSummary includes from-state description for fromTo; PropertyRow and AddPropertyTrigger extracted to eliminate the structural duplication between From and To rows - GsapAnimationSection / PropertyPanel / useDomEditSession / DomEditContext / StudioRightPanel: thread the three new callbacks through the full prop/context chain * test(studio): add API-level tests for fromProperties mutation routes Covers the three new mutation types introduced in the fromTo panel fix: - update-from-property: asserts value written and sibling keys preserved - update-from-property: asserts 400 for non-fromTo animation - add-from-property: asserts new key merged without clobbering existing keys - remove-from-property: asserts targeted key removed, others intact - remove-from-property: asserts 400 for non-fromTo animation - add with method "fromTo": asserts fromProperties written to source All exercised at the HTTP route layer via the same Hono app harness as the existing gsap-mutations tests.
1 parent 62475b7 commit 6a0c9a5

10 files changed

Lines changed: 590 additions & 77 deletions

File tree

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

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,224 @@ describe("registerFileRoutes", () => {
109109
expect(payload.animations[0].targetSelector).toBe(".kicker");
110110
});
111111

112+
// A composition with a fromTo tween — used by the fromProperties mutation tests.
113+
const FROMTO_COMP = `<!DOCTYPE html><html><body data-duration="3">
114+
<div id="box" data-start="0" data-duration="3" style="opacity:0"></div>
115+
<script data-hyperframes-gsap>
116+
const tl = gsap.timeline();
117+
tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, ease: "power2.out" }, 0);
118+
</script>
119+
</body></html>`;
120+
121+
function writeHtml(projectDir: string, name: string, html: string): void {
122+
writeFileSync(join(projectDir, name), html);
123+
}
124+
125+
async function getFirstAnimation(
126+
app: Hono,
127+
file: string,
128+
): Promise<{ id: string; method: string; fromProperties?: Record<string, number | string> }> {
129+
const res = await app.request(`http://localhost/projects/demo/gsap-animations/${file}`);
130+
const payload = (await res.json()) as {
131+
animations: Array<{
132+
id: string;
133+
method: string;
134+
fromProperties?: Record<string, number | string>;
135+
}>;
136+
};
137+
return payload.animations[0];
138+
}
139+
140+
it("update-from-property updates a fromTo start value in place", async () => {
141+
const projectDir = createProjectDir();
142+
writeHtml(projectDir, "comp.html", FROMTO_COMP);
143+
const app = new Hono();
144+
registerFileRoutes(app, createAdapter(projectDir));
145+
146+
const anim = await getFirstAnimation(app, "comp.html");
147+
expect(anim.method).toBe("fromTo");
148+
expect(anim.fromProperties?.opacity).toBe(0);
149+
150+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
151+
method: "POST",
152+
headers: { "Content-Type": "application/json" },
153+
body: JSON.stringify({
154+
type: "update-from-property",
155+
animationId: anim.id,
156+
property: "opacity",
157+
value: 0.2,
158+
}),
159+
});
160+
const result = (await res.json()) as {
161+
ok: boolean;
162+
after: string;
163+
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
164+
};
165+
166+
expect(res.status).toBe(200);
167+
expect(result.ok).toBe(true);
168+
expect(result.after).toContain("opacity: 0.2");
169+
expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2);
170+
// x unchanged
171+
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);
172+
});
173+
174+
it("update-from-property returns 400 for a non-fromTo animation", async () => {
175+
const projectDir = createProjectDir();
176+
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
177+
const tl = gsap.timeline();
178+
tl.to("#box", { opacity: 1, duration: 1 }, 0);
179+
</script></body></html>`;
180+
writeHtml(projectDir, "to.html", TO_COMP);
181+
const app = new Hono();
182+
registerFileRoutes(app, createAdapter(projectDir));
183+
184+
const anim = await getFirstAnimation(app, "to.html");
185+
expect(anim.method).toBe("to");
186+
187+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/to.html", {
188+
method: "POST",
189+
headers: { "Content-Type": "application/json" },
190+
body: JSON.stringify({
191+
type: "update-from-property",
192+
animationId: anim.id,
193+
property: "opacity",
194+
value: 0,
195+
}),
196+
});
197+
198+
expect(res.status).toBe(400);
199+
});
200+
201+
it("add-from-property merges a new key into existing fromProperties", async () => {
202+
const projectDir = createProjectDir();
203+
writeHtml(projectDir, "comp.html", FROMTO_COMP);
204+
const app = new Hono();
205+
registerFileRoutes(app, createAdapter(projectDir));
206+
207+
const anim = await getFirstAnimation(app, "comp.html");
208+
209+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
210+
method: "POST",
211+
headers: { "Content-Type": "application/json" },
212+
body: JSON.stringify({
213+
type: "add-from-property",
214+
animationId: anim.id,
215+
property: "scale",
216+
defaultValue: 0.5,
217+
}),
218+
});
219+
const result = (await res.json()) as {
220+
ok: boolean;
221+
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
222+
};
223+
224+
expect(res.status).toBe(200);
225+
expect(result.ok).toBe(true);
226+
// Existing keys preserved, new key added
227+
const fp = result.parsed.animations[0].fromProperties ?? {};
228+
expect(fp.opacity).toBe(0);
229+
expect(fp.x).toBe(-50);
230+
expect(fp.scale).toBe(0.5);
231+
});
232+
233+
it("remove-from-property deletes one key, leaving others intact", async () => {
234+
const projectDir = createProjectDir();
235+
writeHtml(projectDir, "comp.html", FROMTO_COMP);
236+
const app = new Hono();
237+
registerFileRoutes(app, createAdapter(projectDir));
238+
239+
const anim = await getFirstAnimation(app, "comp.html");
240+
241+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
242+
method: "POST",
243+
headers: { "Content-Type": "application/json" },
244+
body: JSON.stringify({
245+
type: "remove-from-property",
246+
animationId: anim.id,
247+
property: "x",
248+
}),
249+
});
250+
const result = (await res.json()) as {
251+
ok: boolean;
252+
after: string;
253+
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
254+
};
255+
256+
expect(res.status).toBe(200);
257+
expect(result.ok).toBe(true);
258+
const fp = result.parsed.animations[0].fromProperties ?? {};
259+
expect(fp.x).toBeUndefined();
260+
expect(fp.opacity).toBe(0); // untouched
261+
});
262+
263+
it("remove-from-property returns 400 for a non-fromTo animation", async () => {
264+
const projectDir = createProjectDir();
265+
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
266+
const tl = gsap.timeline();
267+
tl.to("#box", { opacity: 1, duration: 1 }, 0);
268+
</script></body></html>`;
269+
writeHtml(projectDir, "to.html", TO_COMP);
270+
const app = new Hono();
271+
registerFileRoutes(app, createAdapter(projectDir));
272+
273+
const anim = await getFirstAnimation(app, "to.html");
274+
275+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/to.html", {
276+
method: "POST",
277+
headers: { "Content-Type": "application/json" },
278+
body: JSON.stringify({
279+
type: "remove-from-property",
280+
animationId: anim.id,
281+
property: "opacity",
282+
}),
283+
});
284+
285+
expect(res.status).toBe(400);
286+
});
287+
288+
it("add mutation with fromTo method creates a fromTo tween with fromProperties", async () => {
289+
const projectDir = createProjectDir();
290+
const EMPTY_COMP = `<!DOCTYPE html><html><body><div id="el"></div><script data-hyperframes-gsap>
291+
const tl = gsap.timeline();
292+
</script></body></html>`;
293+
writeHtml(projectDir, "empty.html", EMPTY_COMP);
294+
const app = new Hono();
295+
registerFileRoutes(app, createAdapter(projectDir));
296+
297+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/empty.html", {
298+
method: "POST",
299+
headers: { "Content-Type": "application/json" },
300+
body: JSON.stringify({
301+
type: "add",
302+
targetSelector: "#el",
303+
method: "fromTo",
304+
position: 0,
305+
duration: 0.5,
306+
ease: "power2.out",
307+
properties: { opacity: 1 },
308+
fromProperties: { opacity: 0 },
309+
}),
310+
});
311+
const result = (await res.json()) as {
312+
ok: boolean;
313+
parsed: {
314+
animations: Array<{
315+
method: string;
316+
fromProperties?: Record<string, number | string>;
317+
properties: Record<string, number | string>;
318+
}>;
319+
};
320+
};
321+
322+
expect(res.status).toBe(200);
323+
expect(result.ok).toBe(true);
324+
const anim = result.parsed.animations[0];
325+
expect(anim.method).toBe("fromTo");
326+
expect(anim.fromProperties?.opacity).toBe(0);
327+
expect(anim.properties.opacity).toBe(1);
328+
});
329+
112330
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
113331
const projectDir = createProjectDir();
114332
writeComp(projectDir, "scene.html", TEMPLATE_COMP);

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

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
539539
property: string;
540540
value: number | string;
541541
}
542+
| {
543+
type: "update-from-property";
544+
animationId: string;
545+
property: string;
546+
value: number | string;
547+
}
542548
| {
543549
type: "update-meta";
544550
animationId: string;
@@ -547,11 +553,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
547553
| {
548554
type: "add";
549555
targetSelector: string;
550-
method: "to" | "from" | "set";
556+
method: "to" | "from" | "set" | "fromTo";
551557
position: number;
552558
duration?: number;
553559
ease?: string;
554560
properties: Record<string, number | string>;
561+
fromProperties?: Record<string, number | string>;
555562
}
556563
| { type: "delete"; animationId: string }
557564
| {
@@ -560,7 +567,14 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
560567
property: string;
561568
defaultValue: number | string;
562569
}
563-
| { type: "remove-property"; animationId: string; property: string };
570+
| {
571+
type: "add-from-property";
572+
animationId: string;
573+
property: string;
574+
defaultValue: number | string;
575+
}
576+
| { type: "remove-property"; animationId: string; property: string }
577+
| { type: "remove-from-property"; animationId: string; property: string };
564578

565579
api.post("/projects/:id/gsap-mutations/*", async (c) => {
566580
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
@@ -588,6 +602,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
588602

589603
let newScript: string;
590604

605+
// fallow-ignore-next-line complexity
591606
switch (body.type) {
592607
case "update-property": {
593608
const parsed = parseGsapScript(block.scriptText);
@@ -598,6 +613,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
598613
});
599614
break;
600615
}
616+
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);
621+
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
622+
fromProperties: { ...(anim.fromProperties ?? {}), [body.property]: body.value },
623+
});
624+
break;
625+
}
601626
case "update-meta": {
602627
newScript = updateAnimationInScript(block.scriptText, body.animationId, body.updates);
603628
break;
@@ -610,6 +635,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
610635
duration: body.duration,
611636
ease: body.ease,
612637
properties: body.properties,
638+
fromProperties: body.fromProperties,
613639
});
614640
newScript = result.script;
615641
break;
@@ -627,6 +653,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
627653
});
628654
break;
629655
}
656+
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);
661+
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
662+
fromProperties: { ...(anim.fromProperties ?? {}), [body.property]: body.defaultValue },
663+
});
664+
break;
665+
}
630666
case "remove-property": {
631667
const parsed = parseGsapScript(block.scriptText);
632668
const anim = parsed.animations.find((a) => a.id === body.animationId);
@@ -638,6 +674,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
638674
});
639675
break;
640676
}
677+
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 ?? {}) };
683+
delete filtered[body.property];
684+
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
685+
fromProperties: filtered,
686+
});
687+
break;
688+
}
641689
default:
642690
return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
643691
}

‎packages/studio/src/components/StudioRightPanel.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ export function StudioRightPanel({
8787
handleGsapAddAnimation,
8888
handleGsapAddProperty,
8989
handleGsapRemoveProperty,
90+
handleGsapUpdateFromProperty,
91+
handleGsapAddFromProperty,
92+
handleGsapRemoveFromProperty,
9093
} = useDomEditContext();
9194

9295
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
@@ -215,6 +218,9 @@ export function StudioRightPanel({
215218
onDeleteGsapAnimation={handleGsapDeleteAnimation}
216219
onAddGsapProperty={handleGsapAddProperty}
217220
onRemoveGsapProperty={handleGsapRemoveProperty}
221+
onUpdateGsapFromProperty={handleGsapUpdateFromProperty}
222+
onAddGsapFromProperty={handleGsapAddFromProperty}
223+
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
218224
onAddGsapAnimation={handleGsapAddAnimation}
219225
/>
220226
) : motionPanelActive ? (

0 commit comments

Comments
 (0)