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.
This commit is contained in:
Miguel Ángel
2026-05-29 13:18:05 -04:00
committed by GitHub
parent 62475b7649
commit 6a0c9a5e22
10 changed files with 590 additions and 77 deletions
@@ -109,6 +109,224 @@ describe("registerFileRoutes", () => {
expect(payload.animations[0].targetSelector).toBe(".kicker");
});
// A composition with a fromTo tween — used by the fromProperties mutation tests.
const FROMTO_COMP = `<!DOCTYPE html><html><body data-duration="3">
<div id="box" data-start="0" data-duration="3" style="opacity:0"></div>
<script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, ease: "power2.out" }, 0);
</script>
</body></html>`;
function writeHtml(projectDir: string, name: string, html: string): void {
writeFileSync(join(projectDir, name), html);
}
async function getFirstAnimation(
app: Hono,
file: string,
): Promise<{ id: string; method: string; fromProperties?: Record<string, number | string> }> {
const res = await app.request(`http://localhost/projects/demo/gsap-animations/${file}`);
const payload = (await res.json()) as {
animations: Array<{
id: string;
method: string;
fromProperties?: Record<string, number | string>;
}>;
};
return payload.animations[0];
}
it("update-from-property updates a fromTo start value in place", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "comp.html");
expect(anim.method).toBe("fromTo");
expect(anim.fromProperties?.opacity).toBe(0);
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "update-from-property",
animationId: anim.id,
property: "opacity",
value: 0.2,
}),
});
const result = (await res.json()) as {
ok: boolean;
after: string;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.after).toContain("opacity: 0.2");
expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2);
// x unchanged
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);
});
it("update-from-property returns 400 for a non-fromTo animation", async () => {
const projectDir = createProjectDir();
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { opacity: 1, duration: 1 }, 0);
</script></body></html>`;
writeHtml(projectDir, "to.html", TO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "to.html");
expect(anim.method).toBe("to");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/to.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "update-from-property",
animationId: anim.id,
property: "opacity",
value: 0,
}),
});
expect(res.status).toBe(400);
});
it("add-from-property merges a new key into existing fromProperties", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "comp.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "add-from-property",
animationId: anim.id,
property: "scale",
defaultValue: 0.5,
}),
});
const result = (await res.json()) as {
ok: boolean;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
// Existing keys preserved, new key added
const fp = result.parsed.animations[0].fromProperties ?? {};
expect(fp.opacity).toBe(0);
expect(fp.x).toBe(-50);
expect(fp.scale).toBe(0.5);
});
it("remove-from-property deletes one key, leaving others intact", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "comp.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "remove-from-property",
animationId: anim.id,
property: "x",
}),
});
const result = (await res.json()) as {
ok: boolean;
after: string;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
const fp = result.parsed.animations[0].fromProperties ?? {};
expect(fp.x).toBeUndefined();
expect(fp.opacity).toBe(0); // untouched
});
it("remove-from-property returns 400 for a non-fromTo animation", async () => {
const projectDir = createProjectDir();
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { opacity: 1, duration: 1 }, 0);
</script></body></html>`;
writeHtml(projectDir, "to.html", TO_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "to.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/to.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "remove-from-property",
animationId: anim.id,
property: "opacity",
}),
});
expect(res.status).toBe(400);
});
it("add mutation with fromTo method creates a fromTo tween with fromProperties", async () => {
const projectDir = createProjectDir();
const EMPTY_COMP = `<!DOCTYPE html><html><body><div id="el"></div><script data-hyperframes-gsap>
const tl = gsap.timeline();
</script></body></html>`;
writeHtml(projectDir, "empty.html", EMPTY_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/gsap-mutations/empty.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "add",
targetSelector: "#el",
method: "fromTo",
position: 0,
duration: 0.5,
ease: "power2.out",
properties: { opacity: 1 },
fromProperties: { opacity: 0 },
}),
});
const result = (await res.json()) as {
ok: boolean;
parsed: {
animations: Array<{
method: string;
fromProperties?: Record<string, number | string>;
properties: Record<string, number | string>;
}>;
};
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
const anim = result.parsed.animations[0];
expect(anim.method).toBe("fromTo");
expect(anim.fromProperties?.opacity).toBe(0);
expect(anim.properties.opacity).toBe(1);
});
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
+50 -2
View File
@@ -539,6 +539,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
property: string;
value: number | string;
}
| {
type: "update-from-property";
animationId: string;
property: string;
value: number | string;
}
| {
type: "update-meta";
animationId: string;
@@ -547,11 +553,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
| {
type: "add";
targetSelector: string;
method: "to" | "from" | "set";
method: "to" | "from" | "set" | "fromTo";
position: number;
duration?: number;
ease?: string;
properties: Record<string, number | string>;
fromProperties?: Record<string, number | string>;
}
| { type: "delete"; animationId: string }
| {
@@ -560,7 +567,14 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
property: string;
defaultValue: number | string;
}
| { type: "remove-property"; animationId: string; property: string };
| {
type: "add-from-property";
animationId: string;
property: string;
defaultValue: number | string;
}
| { type: "remove-property"; animationId: string; property: string }
| { type: "remove-from-property"; animationId: string; property: string };
api.post("/projects/:id/gsap-mutations/*", async (c) => {
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
@@ -588,6 +602,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
let newScript: string;
// fallow-ignore-next-line complexity
switch (body.type) {
case "update-property": {
const parsed = parseGsapScript(block.scriptText);
@@ -598,6 +613,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
break;
}
case "update-from-property": {
const parsed = parseGsapScript(block.scriptText);
const anim = parsed.animations.find((a) => a.id === body.animationId);
if (!anim) return c.json({ error: "animation not found" }, 404);
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: { ...(anim.fromProperties ?? {}), [body.property]: body.value },
});
break;
}
case "update-meta": {
newScript = updateAnimationInScript(block.scriptText, body.animationId, body.updates);
break;
@@ -610,6 +635,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
duration: body.duration,
ease: body.ease,
properties: body.properties,
fromProperties: body.fromProperties,
});
newScript = result.script;
break;
@@ -627,6 +653,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
break;
}
case "add-from-property": {
const parsed = parseGsapScript(block.scriptText);
const anim = parsed.animations.find((a) => a.id === body.animationId);
if (!anim) return c.json({ error: "animation not found" }, 404);
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: { ...(anim.fromProperties ?? {}), [body.property]: body.defaultValue },
});
break;
}
case "remove-property": {
const parsed = parseGsapScript(block.scriptText);
const anim = parsed.animations.find((a) => a.id === body.animationId);
@@ -638,6 +674,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
break;
}
case "remove-from-property": {
const parsed = parseGsapScript(block.scriptText);
const anim = parsed.animations.find((a) => a.id === body.animationId);
if (!anim) return c.json({ error: "animation not found" }, 404);
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
const filtered = { ...(anim.fromProperties ?? {}) };
delete filtered[body.property];
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: filtered,
});
break;
}
default:
return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
}