mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user