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