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
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { getAnimationsForElement } from "./useGsapTweenCache";
function anim(targetSelector: string): GsapAnimation {
return {
id: `${targetSelector}-to-0`,
targetSelector,
method: "to",
position: 0,
properties: {},
};
}
describe("getAnimationsForElement", () => {
const animations = [anim("#hero"), anim(".kicker"), anim(".kicker"), anim(".co-new")];
it("matches tweens by element id", () => {
const result = getAnimationsForElement(animations, { id: "hero" });
expect(result.map((a) => a.targetSelector)).toEqual(["#hero"]);
});
it("matches class-targeted tweens by the element's selector", () => {
// Real compositions target tweens by class (querySelector(".kicker")); the
// selected element has no id, so id-only matching would miss these.
const result = getAnimationsForElement(animations, { id: null, selector: ".kicker" });
expect(result).toHaveLength(2);
expect(result.every((a) => a.targetSelector === ".kicker")).toBe(true);
});
it("matches by id or selector when both are present", () => {
const result = getAnimationsForElement(animations, { id: "hero", selector: ".co-new" });
expect(result.map((a) => a.targetSelector).sort()).toEqual(["#hero", ".co-new"]);
});
it("returns nothing when neither id nor selector is provided", () => {
expect(getAnimationsForElement(animations, {})).toEqual([]);
expect(getAnimationsForElement(animations, { id: null, selector: null })).toEqual([]);
});
});