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
@@ -202,7 +202,9 @@ export function useDomEditSession({
} = useGsapAnimationsForElement(
STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
domEditSelection?.sourceFile || activeCompPath || "index.html",
domEditSelection?.id ?? null,
domEditSelection
? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
: null,
gsapCacheVersion,
);
@@ -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([]);
});
});
+29 -5
View File
@@ -1,8 +1,27 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
function getAnimationsForElement(animations: GsapAnimation[], elementId: string): GsapAnimation[] {
return animations.filter((a) => a.targetSelector === `#${elementId}`);
/** The selected element's identity for matching tweens to it. */
export interface GsapElementTarget {
id?: string | null;
selector?: string | null;
}
/**
* A tween belongs to the selected element when its target selector addresses
* that element — either by id (`#id`) or by the exact CSS selector the element
* was selected through (`.kicker`). Real compositions target tweens by class
* via `querySelector`, so id-only matching misses them.
*/
export function getAnimationsForElement(
animations: GsapAnimation[],
target: GsapElementTarget,
): GsapAnimation[] {
const matchers = new Set<string>();
if (target.id) matchers.add(`#${target.id}`);
if (target.selector) matchers.add(target.selector);
if (matchers.size === 0) return [];
return animations.filter((a) => matchers.has(a.targetSelector));
}
async function fetchParsedAnimations(
@@ -22,7 +41,7 @@ async function fetchParsedAnimations(
export function useGsapAnimationsForElement(
projectId: string | null,
sourceFile: string,
elementId: string | null,
target: GsapElementTarget | null,
version: number,
): {
animations: GsapAnimation[];
@@ -65,9 +84,14 @@ export function useGsapAnimationsForElement(
};
}, [projectId, sourceFile, version]);
const targetId = target?.id ?? null;
const targetSelector = target?.selector ?? null;
const animations = useMemo(
() => (elementId ? getAnimationsForElement(allAnimations, elementId) : []),
[allAnimations, elementId],
() =>
targetId || targetSelector
? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector })
: [],
[allAnimations, targetId, targetSelector],
);
return { animations, multipleTimelines, unsupportedTimelinePattern };