fix(studio): make GSAP tween editing work on real compositions (#1115)

The Design-panel GSAP editor only recognized tweens written as
tl.to(".selector", {...}) with inline string-literal targets, in a
contiguous block, with no interleaved setup. Every scaffolded
composition instead targets tweens through element variables
(const kicker = root.querySelector(".kicker"); tl.to(kicker, {...})),
wraps the script in an IIFE, and interleaves gsap.set() calls — so the
parser returned zero animations and the panel was inert.

Three coordinated fixes make it work end to end:

- Parser read: resolve querySelector / querySelectorAll / getElementById
  variable targets (and inline lookup calls) back to their CSS selector,
  so variable-targeted tweens are recognized.

- Parser write: replace the full re-serialize (preamble + tweens +
  postamble) with in-place recast AST mutation. Edits now touch only the
  targeted tween's vars/position node and reprint, preserving every
  surrounding statement — gsap.set calls, element declarations, the IIFE
  wrapper, comments and formatting. Previously the first edit would
  discard all of that.

- Linter: build overlap/clip windows directly from the parser's
  structured animations instead of a regex walk paired positionally with
  the parsed list. The old pairing skipped variable targets and would
  drift once the parser started returning them. Removes the now-dead
  regex meta helpers.

- studio-api: extractGsapScriptBlock now searches inside <template>
  content (sub-compositions wrap markup + the GSAP script in a template,
  which linkedom's querySelectorAll doesn't descend into), and the
  frontend matches tweens to the selected element by id OR selector
  rather than id only (class-targeted elements have no id).

Verified end to end against a real 10-scene project: all compositions
now parse (previously 0), the panel populates editable tween cards, and
property/duration/ease edits round-trip while leaving the rest of the
script byte-for-byte intact.
This commit is contained in:
Miguel Ángel
2026-05-28 20:54:44 -04:00
committed by GitHub
parent 2f3ab9f4c9
commit 4de054e7d4
9 changed files with 753 additions and 186 deletions
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFileRoutes } from "./files";
@@ -63,4 +63,89 @@ describe("registerFileRoutes", () => {
expect(response.status).toBe(404);
});
// A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
// targeting element variables resolved from querySelector, with interleaved
// gsap.set() calls. This is the shape every scaffolded composition uses.
const TEMPLATE_COMP = `<template id="scene-template">
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080" data-start="0" data-duration="3">
<div class="kicker">HELLO</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
(function () {
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const root = document.querySelector('#scene');
const kicker = root.querySelector(".kicker");
gsap.set(kicker, { y: 16, opacity: 0 });
tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3);
window.__timelines["scene"] = tl;
})();
</script>
</template>`;
function writeComp(projectDir: string, name: string, html: string): void {
const dir = join(projectDir, "compositions");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, name), html);
}
it("parses GSAP tweens from a <template>-wrapped sub-composition with variable targets", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/gsap-animations/compositions/scene.html",
);
const payload = (await response.json()) as {
animations: Array<{ id: string; targetSelector: string; properties: Record<string, number> }>;
};
expect(response.status).toBe(200);
expect(payload.animations).toHaveLength(1);
expect(payload.animations[0].targetSelector).toBe(".kicker");
});
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const parseRes = await app.request(
"http://localhost/projects/demo/gsap-animations/compositions/scene.html",
);
const { animations } = (await parseRes.json()) as { animations: Array<{ id: string }> };
const animationId = animations[0].id;
const mutateRes = await app.request(
"http://localhost/projects/demo/gsap-mutations/compositions/scene.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "update-property",
animationId,
property: "opacity",
value: 0.5,
}),
},
);
const result = (await mutateRes.json()) as { ok: boolean; after: string };
expect(mutateRes.status).toBe(200);
expect(result.ok).toBe(true);
// Edit landed
expect(result.after).toContain("opacity: 0.5");
// Surrounding code preserved verbatim — the in-place AST edit didn't rewrite the block
expect(result.after).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
expect(result.after).toContain('const kicker = root.querySelector(".kicker")');
expect(result.after).toContain('window.__timelines["scene"] = tl;');
expect(result.after).toContain("(function () {");
// The variable target was not flattened to a string-literal selector
expect(result.after).toContain("tl.to(kicker,");
});
});
+11 -1
View File
@@ -194,7 +194,17 @@ function extractGsapScriptBlock(
html: string,
): { scriptText: string; replaceScript: (newText: string) => string } | null {
const { document } = parseHTML(html);
const scripts = document.querySelectorAll("script:not([src])");
// linkedom's querySelectorAll doesn't descend into <template> content, but
// sub-compositions wrap their markup (and the GSAP <script>) in a <template>.
// Search top-level scripts first, then each template's own scripts. Operate
// on the template element directly (NOT .content) so textContent writes are
// reflected in document.toString().
const scripts = [
...document.querySelectorAll("script:not([src])"),
...Array.from(document.querySelectorAll("template")).flatMap((tmpl) =>
Array.from(tmpl.querySelectorAll("script:not([src])")),
),
];
for (const script of scripts) {
const content = script.textContent || "";
if (