mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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:
@@ -1,8 +1,12 @@
|
||||
interface LintParsedGsap {
|
||||
animations: Array<{
|
||||
targetSelector: string;
|
||||
method: string;
|
||||
position: number | string;
|
||||
properties: Record<string, number | string>;
|
||||
duration?: number;
|
||||
ease?: string;
|
||||
extras?: Record<string, unknown>;
|
||||
}>;
|
||||
timelineVar: string;
|
||||
}
|
||||
@@ -39,7 +43,6 @@ type CompositionRange = {
|
||||
end: number;
|
||||
};
|
||||
|
||||
const META_GSAP_KEYS = new Set(["duration", "ease", "repeat", "yoyo", "overwrite", "delay"]);
|
||||
const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
|
||||
|
||||
// ── GSAP parsing utilities ─────────────────────────────────────────────────
|
||||
@@ -125,6 +128,39 @@ function readRegisteredTimelineCompositionId(script: string): string | null {
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
/** Strip a `__raw:` prefix the parser adds to unresolvable values. */
|
||||
function unwrapRaw(value: unknown): string | number | undefined {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value !== "string") return undefined;
|
||||
const code = value.startsWith("__raw:") ? value.slice(6) : value;
|
||||
return code.replace(/^\s*["']|["']\s*$/g, "");
|
||||
}
|
||||
|
||||
function extrasNumber(value: unknown): number {
|
||||
const unwrapped = unwrapRaw(value);
|
||||
const numeric = typeof unwrapped === "number" ? unwrapped : Number(unwrapped);
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
}
|
||||
|
||||
/** A readable single-line snippet of a tween for finding messages. */
|
||||
function synthesizeWindowRaw(
|
||||
timelineVar: string,
|
||||
anim: LintParsedGsap["animations"][number],
|
||||
): string {
|
||||
const entries = Object.entries(anim.properties).map(([k, v]) => {
|
||||
if (typeof v === "string" && v.startsWith("__raw:")) return `${k}: ${v.slice(6)}`;
|
||||
return `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`;
|
||||
});
|
||||
if (anim.duration !== undefined) entries.push(`duration: ${anim.duration}`);
|
||||
if (anim.ease) entries.push(`ease: ${JSON.stringify(anim.ease)}`);
|
||||
const pos = typeof anim.position === "number" ? anim.position : JSON.stringify(anim.position);
|
||||
return `${timelineVar}.${anim.method}("${anim.targetSelector}", { ${entries.join(", ")} }, ${pos})`;
|
||||
}
|
||||
|
||||
// Build lint windows straight from the parser's structured animations. The
|
||||
// parser already resolves variable targets (`tl.to(kicker, …)`) to selectors
|
||||
// and excludes non-DOM object-target anchors (`tl.to({ _: 0 }, …)`), so there's
|
||||
// no fragile positional pairing between a regex walk and the parsed list.
|
||||
async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
|
||||
if (!/gsap\.timeline/.test(script)) return [];
|
||||
const parseGsapScript = await loadParseGsapScript();
|
||||
@@ -132,144 +168,28 @@ async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
|
||||
if (parsed.animations.length === 0) return [];
|
||||
|
||||
const windows: GsapWindow[] = [];
|
||||
const timelineVar = parsed.timelineVar;
|
||||
const methodPattern = new RegExp(
|
||||
`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`,
|
||||
"g",
|
||||
);
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
let index = 0;
|
||||
while ((match = methodPattern.exec(script)) !== null && index < parsed.animations.length) {
|
||||
const raw = match[0];
|
||||
const args = match[2] ?? "";
|
||||
// Skip calls whose first argument is not a quoted selector (e.g. object
|
||||
// targets like `tl.to({ _: 0 }, …)` used to anchor timeline duration).
|
||||
// `parseGsapScript` ignores those, so we must too — otherwise the regex
|
||||
// match index drifts ahead of `parsed.animations[index]` and every
|
||||
// subsequent window picks up the wrong animation's selector/position.
|
||||
if (!/^\s*["']/.test(args)) continue;
|
||||
const meta = parseGsapWindowMeta(match[1] ?? "", args);
|
||||
const animation = parsed.animations[index];
|
||||
index += 1;
|
||||
if (!animation) continue;
|
||||
for (const animation of parsed.animations) {
|
||||
// Skip animations with string positions (e.g. "+=1", "<") — their absolute
|
||||
// timing depends on runtime evaluation and can't be statically linted.
|
||||
if (typeof animation.position !== "number") continue;
|
||||
const repeat = extrasNumber(animation.extras?.repeat);
|
||||
const cycleCount = repeat > 0 ? repeat + 1 : 1;
|
||||
const effectiveDuration =
|
||||
animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
|
||||
windows.push({
|
||||
targetSelector: animation.targetSelector,
|
||||
position: animation.position,
|
||||
end: animation.position + meta.effectiveDuration,
|
||||
properties: meta.properties.length > 0 ? meta.properties : Object.keys(animation.properties),
|
||||
propertyValues: meta.propertyValues,
|
||||
overwriteAuto: meta.overwriteAuto,
|
||||
method: match[1] ?? "to",
|
||||
raw,
|
||||
end: animation.position + effectiveDuration,
|
||||
properties: Object.keys(animation.properties),
|
||||
propertyValues: animation.properties,
|
||||
overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
|
||||
method: animation.method,
|
||||
raw: synthesizeWindowRaw(parsed.timelineVar, animation),
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function parseGsapWindowMeta(
|
||||
method: string,
|
||||
argsStr: string,
|
||||
): {
|
||||
effectiveDuration: number;
|
||||
properties: string[];
|
||||
propertyValues: Record<string, string | number>;
|
||||
overwriteAuto: boolean;
|
||||
} {
|
||||
const emptyMeta = {
|
||||
effectiveDuration: 0,
|
||||
properties: [],
|
||||
propertyValues: {},
|
||||
overwriteAuto: false,
|
||||
};
|
||||
const selectorMatch = argsStr.match(/^\s*["']([^"']+)["']\s*,/);
|
||||
if (!selectorMatch) return emptyMeta;
|
||||
|
||||
const afterSelector = argsStr.slice(selectorMatch[0].length);
|
||||
let properties: Record<string, string | number> = {};
|
||||
let fromProperties: Record<string, string | number> = {};
|
||||
|
||||
if (method === "fromTo") {
|
||||
const firstBrace = afterSelector.indexOf("{");
|
||||
const firstEnd = findMatchingBrace(afterSelector, firstBrace);
|
||||
if (firstBrace !== -1 && firstEnd !== -1) {
|
||||
fromProperties = parseLooseObjectLiteral(afterSelector.slice(firstBrace, firstEnd + 1));
|
||||
const secondPart = afterSelector.slice(firstEnd + 1);
|
||||
const secondBrace = secondPart.indexOf("{");
|
||||
const secondEnd = findMatchingBrace(secondPart, secondBrace);
|
||||
if (secondBrace !== -1 && secondEnd !== -1) {
|
||||
properties = parseLooseObjectLiteral(secondPart.slice(secondBrace, secondEnd + 1));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const braceStart = afterSelector.indexOf("{");
|
||||
const braceEnd = findMatchingBrace(afterSelector, braceStart);
|
||||
if (braceStart !== -1 && braceEnd !== -1) {
|
||||
properties = parseLooseObjectLiteral(afterSelector.slice(braceStart, braceEnd + 1));
|
||||
}
|
||||
}
|
||||
|
||||
const duration = numberValue(properties.duration) || 0;
|
||||
const repeat = numberValue(properties.repeat) || 0;
|
||||
const cycleCount = repeat > 0 ? repeat + 1 : 1;
|
||||
const effectiveDuration = duration * cycleCount;
|
||||
const overwriteAuto = stringValue(properties.overwrite) === "auto";
|
||||
|
||||
const propertyNames = new Set<string>();
|
||||
for (const key of Object.keys(fromProperties)) {
|
||||
if (!META_GSAP_KEYS.has(key)) propertyNames.add(key);
|
||||
}
|
||||
for (const key of Object.keys(properties)) {
|
||||
if (!META_GSAP_KEYS.has(key)) propertyNames.add(key);
|
||||
}
|
||||
|
||||
return {
|
||||
effectiveDuration: method === "set" ? 0 : effectiveDuration,
|
||||
properties: [...propertyNames],
|
||||
propertyValues: properties,
|
||||
overwriteAuto,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLooseObjectLiteral(source: string): Record<string, string | number> {
|
||||
const result: Record<string, string | number> = {};
|
||||
const cleaned = source.replace(/^\{|\}$/g, "").trim();
|
||||
if (!cleaned) return result;
|
||||
const propertyPattern = /(\w+)\s*:\s*("[^"]*"|'[^']*'|true|false|-?[\d.]+|[a-zA-Z_][\w.]*)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = propertyPattern.exec(cleaned)) !== null) {
|
||||
const key = match[1];
|
||||
const rawValue = match[2];
|
||||
if (!key || rawValue == null) continue;
|
||||
if (
|
||||
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
|
||||
(rawValue.startsWith("'") && rawValue.endsWith("'"))
|
||||
) {
|
||||
result[key] = rawValue.slice(1, -1);
|
||||
continue;
|
||||
}
|
||||
const numeric = Number(rawValue);
|
||||
result[key] = Number.isFinite(numeric) ? numeric : rawValue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findMatchingBrace(source: string, startIndex: number): number {
|
||||
if (startIndex < 0) return -1;
|
||||
let depth = 0;
|
||||
for (let i = startIndex; i < source.length; i++) {
|
||||
if (source[i] === "{") depth += 1;
|
||||
else if (source[i] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function numberValue(value: string | number | undefined): number | null {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
|
||||
@@ -856,7 +856,7 @@ describe("Additional edge cases", () => {
|
||||
expect(result.animations[0].targetSelector).toBe("#el2");
|
||||
});
|
||||
|
||||
it("non-string selector (variable reference) is skipped", () => {
|
||||
it("resolves a variable reference selector to its queried CSS selector", () => {
|
||||
const script = `
|
||||
const el = document.querySelector("#el");
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
@@ -864,7 +864,20 @@ describe("Additional edge cases", () => {
|
||||
tl.to("#el2", { x: 100, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
// First tween has a variable reference as selector, not a string literal — skipped
|
||||
// `el` is bound to `document.querySelector("#el")`, so it resolves to "#el".
|
||||
expect(result.animations).toHaveLength(2);
|
||||
expect(result.animations[0].targetSelector).toBe("#el");
|
||||
expect(result.animations[1].targetSelector).toBe("#el2");
|
||||
});
|
||||
|
||||
it("skips a variable target that is not bound to a DOM lookup", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(mysteryTarget, { opacity: 1, duration: 0.5 }, 0);
|
||||
tl.to("#el2", { x: 100, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
// mysteryTarget has no resolvable selector binding — only the literal survives.
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].targetSelector).toBe("#el2");
|
||||
});
|
||||
|
||||
@@ -813,3 +813,211 @@ describe("SUPPORTED_EASES", () => {
|
||||
expect(SUPPORTED_EASES).toContain("elastic.inOut");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Variable-target resolution + in-place mutation ──────────────────────────
|
||||
//
|
||||
// Real compositions (and everything the hyperframes skill generates) target
|
||||
// tweens via element variables resolved from querySelector, wrapped in an IIFE,
|
||||
// with gsap.set() calls interleaved between tl.to() calls. The parser must
|
||||
// resolve those variable targets to selectors (read) and edits must preserve
|
||||
// every surrounding statement (write).
|
||||
|
||||
const REAL_WORLD_SCRIPT = `(function () {
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const root = document.querySelector('#cold-open');
|
||||
const kicker = root.querySelector(".co-kicker");
|
||||
const glyph = root.querySelector(".co-new");
|
||||
const items = root.querySelectorAll(".co-item");
|
||||
|
||||
gsap.set(kicker, { y: 16, opacity: 0 });
|
||||
tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3);
|
||||
|
||||
gsap.set(glyph, { rotationX: 90, opacity: 0 });
|
||||
tl.to(glyph, { rotationX: 0, opacity: 1, duration: 0.5, ease: "power3.inOut" }, 2.06);
|
||||
|
||||
tl.to(items, { opacity: 1, duration: 0.4, stagger: 0.1 }, 1.0);
|
||||
|
||||
window.__timelines["cold-open"] = tl;
|
||||
})();`;
|
||||
|
||||
describe("variable-target resolution (querySelector pattern)", () => {
|
||||
it("resolves a const element variable to its selector", () => {
|
||||
const script = `
|
||||
const root = document.querySelector('#scene');
|
||||
const kicker = root.querySelector(".co-kicker");
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].targetSelector).toBe(".co-kicker");
|
||||
expect(result.animations[0].properties.opacity).toBe(1);
|
||||
expect(result.animations[0].duration).toBe(0.45);
|
||||
expect(result.animations[0].ease).toBe("expo.out");
|
||||
});
|
||||
|
||||
it("resolves document.querySelector and querySelectorAll targets", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const title = document.querySelector("#title");
|
||||
const items = document.querySelectorAll(".item");
|
||||
tl.to(title, { opacity: 1, duration: 0.5 }, 0);
|
||||
tl.to(items, { y: 0, duration: 0.5, stagger: 0.1 }, 0.5);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(2);
|
||||
expect(result.animations[0].targetSelector).toBe("#title");
|
||||
expect(result.animations[1].targetSelector).toBe(".item");
|
||||
});
|
||||
|
||||
it("resolves getElementById targets to an id selector", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const el = document.getElementById("hero");
|
||||
tl.to(el, { opacity: 1, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].targetSelector).toBe("#hero");
|
||||
});
|
||||
|
||||
it("resolves an inline querySelector call passed directly as the target", () => {
|
||||
const script = `
|
||||
const root = document.querySelector('#scene');
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(root.querySelector(".inline"), { opacity: 1, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].targetSelector).toBe(".inline");
|
||||
});
|
||||
|
||||
it("parses mixed string-literal and variable targets in one timeline", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const kicker = document.querySelector(".kicker");
|
||||
tl.to(".literal", { opacity: 1, duration: 0.5 }, 0);
|
||||
tl.to(kicker, { y: 0, duration: 0.5 }, 0.5);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations.map((a) => a.targetSelector)).toEqual([".literal", ".kicker"]);
|
||||
});
|
||||
|
||||
it("parses every tween in a real-world IIFE composition with interleaved gsap.set", () => {
|
||||
const result = parseGsapScript(REAL_WORLD_SCRIPT);
|
||||
expect(result.animations.map((a) => a.targetSelector)).toEqual([
|
||||
".co-kicker",
|
||||
".co-new",
|
||||
".co-item",
|
||||
]);
|
||||
// stagger preserved as extras
|
||||
expect(result.animations[2].extras?.stagger).toBe("__raw:0.1");
|
||||
});
|
||||
|
||||
it("leaves unresolvable variable targets out of the animation list", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(someUnknownThing, { opacity: 1, duration: 0.5 }, 0);
|
||||
tl.to(".real", { opacity: 1, duration: 0.5 }, 1);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations.map((a) => a.targetSelector)).toEqual([".real"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in-place AST mutation preserves surrounding code", () => {
|
||||
it("updateAnimationInScript edits one tween and preserves gsap.set + var decls + IIFE", () => {
|
||||
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
|
||||
const kickerAnim = parsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
|
||||
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, kickerAnim.id, {
|
||||
properties: { y: 0, opacity: 0.5 },
|
||||
});
|
||||
|
||||
// The edit landed
|
||||
expect(updated).toContain("opacity: 0.5");
|
||||
// Surrounding code survived verbatim
|
||||
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
|
||||
expect(updated).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
|
||||
expect(updated).toContain("gsap.set(glyph, { rotationX: 90, opacity: 0 })");
|
||||
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
|
||||
expect(updated).toContain("(function () {");
|
||||
// The variable target was NOT rewritten to a string literal
|
||||
expect(updated).toContain("tl.to(kicker,");
|
||||
expect(updated).not.toContain('tl.to(".co-kicker"');
|
||||
// The other tweens are untouched
|
||||
expect(updated).toContain("tl.to(glyph,");
|
||||
expect(updated).toContain("tl.to(items,");
|
||||
});
|
||||
|
||||
it("updateAnimationInScript re-parses to the edited value (round-trip)", () => {
|
||||
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
|
||||
const glyphAnim = parsed.animations.find((a) => a.targetSelector === ".co-new")!;
|
||||
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, glyphAnim.id, {
|
||||
properties: { rotationX: 0, opacity: 1, scale: 1.2 },
|
||||
});
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const reGlyph = reparsed.animations.find((a) => a.targetSelector === ".co-new")!;
|
||||
expect(reGlyph.properties.scale).toBe(1.2);
|
||||
// unrelated tweens still present
|
||||
expect(reparsed.animations).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("update-meta edits duration/ease/position in place", () => {
|
||||
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
|
||||
const kickerAnim = parsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
|
||||
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, kickerAnim.id, {
|
||||
duration: 0.9,
|
||||
ease: "power1.in",
|
||||
});
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const reKicker = reparsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
|
||||
expect(reKicker.duration).toBe(0.9);
|
||||
expect(reKicker.ease).toBe("power1.in");
|
||||
// surrounding code intact
|
||||
expect(updated).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
|
||||
});
|
||||
|
||||
it("removeAnimationFromScript removes one tween and keeps the rest + setup", () => {
|
||||
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
|
||||
const glyphAnim = parsed.animations.find((a) => a.targetSelector === ".co-new")!;
|
||||
const updated = removeAnimationFromScript(REAL_WORLD_SCRIPT, glyphAnim.id);
|
||||
const reparsed = parseGsapScript(updated);
|
||||
expect(reparsed.animations.map((a) => a.targetSelector)).toEqual([".co-kicker", ".co-item"]);
|
||||
// the removed tween's gsap.set setup is left untouched (not the parser's job to remove)
|
||||
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
|
||||
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
|
||||
});
|
||||
|
||||
it("addAnimationToScript inserts a tween and preserves the IIFE body", () => {
|
||||
const { script: updated, id } = addAnimationToScript(REAL_WORLD_SCRIPT, {
|
||||
targetSelector: "#new-el",
|
||||
method: "to",
|
||||
position: 3,
|
||||
duration: 0.5,
|
||||
ease: "power2.out",
|
||||
properties: { opacity: 1 },
|
||||
});
|
||||
expect(id).not.toBe("");
|
||||
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
|
||||
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
|
||||
const reparsed = parseGsapScript(updated);
|
||||
expect(reparsed.animations.some((a) => a.targetSelector === "#new-el")).toBe(true);
|
||||
expect(reparsed.animations).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("still edits classic string-literal timelines in place", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
|
||||
tl.to("#el2", { x: 100, duration: 1 }, 1);
|
||||
`;
|
||||
const parsed = parseGsapScript(script);
|
||||
const updated = updateAnimationInScript(script, parsed.animations[0].id, {
|
||||
properties: { opacity: 0.25 },
|
||||
});
|
||||
expect(updated).toContain("opacity: 0.25");
|
||||
// second tween untouched
|
||||
expect(updated).toContain('tl.to("#el2", { x: 100, duration: 1 }, 1)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,12 +10,7 @@
|
||||
*/
|
||||
import * as recast from "recast";
|
||||
import { parse as babelParse } from "@babel/parser";
|
||||
import {
|
||||
type GsapAnimation,
|
||||
type GsapMethod,
|
||||
type ParsedGsap,
|
||||
serializeGsapAnimations,
|
||||
} from "./gsapSerialize";
|
||||
import { type GsapAnimation, type GsapMethod, type ParsedGsap } from "./gsapSerialize";
|
||||
|
||||
export type { GsapAnimation, GsapMethod, ParsedGsap } from "./gsapSerialize";
|
||||
export {
|
||||
@@ -109,6 +104,81 @@ function extractLiteralValue(node: any, scope: ScopeBindings): unknown {
|
||||
return resolveNode(node, scope);
|
||||
}
|
||||
|
||||
// ── Element-target resolution ───────────────────────────────────────────────
|
||||
//
|
||||
// Real compositions target tweens through element variables resolved from the
|
||||
// DOM (`const kicker = root.querySelector(".kicker"); tl.to(kicker, …)`) rather
|
||||
// than inline string selectors. To make those tweens editable we map each such
|
||||
// variable back to the CSS selector it was queried with.
|
||||
|
||||
const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]);
|
||||
|
||||
/**
|
||||
* If `node` is a DOM lookup call (`x.querySelector(".sel")`,
|
||||
* `document.querySelectorAll(".sel")`, `document.getElementById("id")`),
|
||||
* return the CSS selector it resolves to. `getElementById("id")` maps to
|
||||
* `#id`. Returns null for anything else.
|
||||
*/
|
||||
function selectorFromQueryCall(node: any, scope: ScopeBindings): string | null {
|
||||
if (node?.type !== "CallExpression") return null;
|
||||
const callee = node.callee;
|
||||
if (callee?.type !== "MemberExpression" || callee.property?.type !== "Identifier") return null;
|
||||
const method = callee.property.name;
|
||||
const argValue = resolveNode(node.arguments?.[0], scope);
|
||||
if (typeof argValue !== "string" || argValue.length === 0) return null;
|
||||
if (QUERY_METHODS.has(method)) return argValue;
|
||||
if (method === "getElementById") return `#${argValue}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
type TargetBindings = ReadonlyMap<string, string>;
|
||||
|
||||
/** Map element variables (assigned from a DOM lookup) to their CSS selector. */
|
||||
function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings {
|
||||
const bindings = new Map<string, string>();
|
||||
recast.types.visit(ast, {
|
||||
visitVariableDeclarator(path: any) {
|
||||
const name = path.node.id?.name;
|
||||
const selector = selectorFromQueryCall(path.node.init, scope);
|
||||
if (name && selector !== null) bindings.set(name, selector);
|
||||
this.traverse(path);
|
||||
},
|
||||
visitAssignmentExpression(path: any) {
|
||||
const left = path.node.left;
|
||||
const selector = selectorFromQueryCall(path.node.right, scope);
|
||||
if (left?.type === "Identifier" && selector !== null && !bindings.has(left.name)) {
|
||||
bindings.set(left.name, selector);
|
||||
}
|
||||
this.traverse(path);
|
||||
},
|
||||
});
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tween's first argument to a CSS selector. Handles inline string
|
||||
* literals, element variables (via {@link collectTargetBindings}), and inline
|
||||
* DOM lookup calls. Returns null when the target can't be resolved statically
|
||||
* (e.g. an object-target duration anchor `tl.to({ _: 0 }, …)`).
|
||||
*/
|
||||
function resolveTargetSelector(
|
||||
node: any,
|
||||
scope: ScopeBindings,
|
||||
targetBindings: TargetBindings,
|
||||
): string | null {
|
||||
if (!node) return null;
|
||||
if (node.type === "StringLiteral" || node.type === "Literal") {
|
||||
return typeof node.value === "string" ? node.value : null;
|
||||
}
|
||||
if (node.type === "Identifier") {
|
||||
return targetBindings.get(node.name) ?? null;
|
||||
}
|
||||
if (node.type === "CallExpression") {
|
||||
return selectorFromQueryCall(node, scope);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function objectExpressionToRecord(node: any, scope: ScopeBindings): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
if (node?.type !== "ObjectExpression") return result;
|
||||
@@ -180,7 +250,12 @@ interface TweenCallInfo {
|
||||
positionArg?: any;
|
||||
}
|
||||
|
||||
function findAllTweenCalls(ast: any, timelineVar: string): TweenCallInfo[] {
|
||||
function findAllTweenCalls(
|
||||
ast: any,
|
||||
timelineVar: string,
|
||||
scope: ScopeBindings,
|
||||
targetBindings: TargetBindings,
|
||||
): TweenCallInfo[] {
|
||||
const results: TweenCallInfo[] = [];
|
||||
recast.types.visit(ast, {
|
||||
visitCallExpression(path: any) {
|
||||
@@ -202,11 +277,7 @@ function findAllTweenCalls(ast: any, timelineVar: string): TweenCallInfo[] {
|
||||
this.traverse(path);
|
||||
return;
|
||||
}
|
||||
const selectorArg = args[0];
|
||||
const selectorValue =
|
||||
selectorArg.type === "StringLiteral" || selectorArg.type === "Literal"
|
||||
? String(selectorArg.value)
|
||||
: null;
|
||||
const selectorValue = resolveTargetSelector(args[0], scope, targetBindings);
|
||||
if (!selectorValue) {
|
||||
this.traverse(path);
|
||||
return;
|
||||
@@ -348,16 +419,45 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Shared parse (AST + located tween calls) ────────────────────────────────
|
||||
|
||||
interface ParsedGsapAst {
|
||||
ast: any;
|
||||
scope: ScopeBindings;
|
||||
timelineVar: string;
|
||||
detection: TimelineDetection;
|
||||
/** Tween calls in document order, each paired with its stable animation id. */
|
||||
located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a script to its recast AST plus the located tween calls. The mutation
|
||||
* functions reuse this so they can edit the exact call node in place (recast
|
||||
* preserves all surrounding source — interleaved `gsap.set`, element variable
|
||||
* declarations, the IIFE wrapper, comments and formatting).
|
||||
*/
|
||||
function parseGsapAst(script: string): ParsedGsapAst {
|
||||
const ast = parseScript(script);
|
||||
const scope = collectScopeBindings(ast);
|
||||
const targetBindings = collectTargetBindings(ast, scope);
|
||||
const detection = findTimelineVar(ast);
|
||||
const timelineVar = detection.timelineVar ?? "tl";
|
||||
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
|
||||
const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope)));
|
||||
const located = animations.map((animation, i) => ({
|
||||
id: animation.id,
|
||||
call: calls[i]!,
|
||||
animation,
|
||||
}));
|
||||
return { ast, scope, timelineVar, detection, located };
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function parseGsapScript(script: string): ParsedGsap {
|
||||
try {
|
||||
const ast = parseScript(script);
|
||||
const scope = collectScopeBindings(ast);
|
||||
const detection = findTimelineVar(ast);
|
||||
const timelineVar = detection.timelineVar ?? "tl";
|
||||
const calls = findAllTweenCalls(ast, timelineVar);
|
||||
const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope)));
|
||||
const { detection, timelineVar, located } = parseGsapAst(script);
|
||||
const animations = located.map((l) => l.animation);
|
||||
|
||||
const timelineMatch = script.match(
|
||||
new RegExp(
|
||||
@@ -387,9 +487,136 @@ export function parseGsapScript(script: string): ParsedGsap {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true when the parse result is a failure fallback (no animations, no preamble). */
|
||||
function isParseFailure(parsed: ParsedGsap): boolean {
|
||||
return parsed.animations.length === 0 && !parsed.preamble;
|
||||
// ── In-place AST mutation helpers ───────────────────────────────────────────
|
||||
//
|
||||
// Edits operate directly on the located call's AST node and reprint via recast,
|
||||
// which preserves every untouched statement. This is what lets us edit tweens
|
||||
// in real compositions (variable targets, interleaved `gsap.set`, IIFE wrapper)
|
||||
// without regenerating — and discarding — the surrounding code.
|
||||
|
||||
/** Render a model value to the JS source it should emit as. Mirrors gsapSerialize. */
|
||||
function valueToCode(value: number | string): string {
|
||||
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function safeKey(key: string): string {
|
||||
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a value/expression snippet into a standalone AST expression node.
|
||||
* Uses an assignment (`__hf__ = <code>`) rather than wrapping in parens so an
|
||||
* object literal parses as an expression without recast re-emitting the
|
||||
* surrounding parentheses.
|
||||
*/
|
||||
function parseExpr(code: string): any {
|
||||
return parseScript(`__hf__ = ${code};`).program.body[0].expression.right;
|
||||
}
|
||||
|
||||
function propKeyName(prop: any): string | undefined {
|
||||
return prop?.key?.name ?? prop?.key?.value;
|
||||
}
|
||||
|
||||
function isObjectProperty(prop: any): boolean {
|
||||
return prop?.type === "ObjectProperty" || prop?.type === "Property";
|
||||
}
|
||||
|
||||
/** A key the inspector treats as an editable transform/style property. */
|
||||
function isEditablePropertyKey(key: string): boolean {
|
||||
return !BUILTIN_VAR_KEYS.has(key) && !DROPPED_VAR_KEYS.has(key) && !EXTRAS_KEYS.has(key);
|
||||
}
|
||||
|
||||
function makeObjectProperty(key: string, value: number | string): any {
|
||||
const obj = parseExpr(`{ ${safeKey(key)}: ${valueToCode(value)} }`);
|
||||
return obj.properties[0];
|
||||
}
|
||||
|
||||
/** Set (or insert) a single key on an ObjectExpression, preserving sibling keys. */
|
||||
function setVarsKey(varsArg: any, key: string, value: number | string): void {
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
const existing = varsArg.properties.find(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === key,
|
||||
);
|
||||
if (existing) {
|
||||
existing.value = parseExpr(valueToCode(value));
|
||||
} else {
|
||||
varsArg.properties.push(makeObjectProperty(key, value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the editable-property keys on an ObjectExpression with `newProps`,
|
||||
* leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys
|
||||
* untouched.
|
||||
*/
|
||||
function reconcileEditableProperties(
|
||||
varsArg: any,
|
||||
newProps: Record<string, number | string>,
|
||||
): void {
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
// Drop editable props no longer present.
|
||||
varsArg.properties = varsArg.properties.filter((p: any) => {
|
||||
if (!isObjectProperty(p)) return true;
|
||||
const key = propKeyName(p);
|
||||
if (typeof key !== "string") return true;
|
||||
if (!isEditablePropertyKey(key)) return true;
|
||||
return key in newProps;
|
||||
});
|
||||
// Upsert each new prop, preserving the order keys first appeared.
|
||||
for (const [key, value] of Object.entries(newProps)) {
|
||||
setVarsKey(varsArg, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>): void {
|
||||
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
|
||||
if (updates.fromProperties && call.method === "fromTo") {
|
||||
reconcileEditableProperties(call.fromArg, updates.fromProperties);
|
||||
}
|
||||
if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration);
|
||||
if (updates.ease !== undefined) setVarsKey(call.varsArg, "ease", updates.ease);
|
||||
if (updates.position !== undefined) {
|
||||
const posIdx = call.method === "fromTo" ? 3 : 2;
|
||||
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk up to the enclosing ExpressionStatement path (for prune / insertAfter). */
|
||||
function findStatementPath(path: any): any {
|
||||
let p = path;
|
||||
while (p) {
|
||||
if (p.node?.type === "ExpressionStatement") return p;
|
||||
p = p.parentPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the source for a single `tl.method(selector, vars, position)` call. */
|
||||
function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation, "id">): string {
|
||||
const selector = JSON.stringify(anim.targetSelector);
|
||||
const props: Record<string, number | string> = { ...anim.properties };
|
||||
if (anim.duration !== undefined) props.duration = anim.duration;
|
||||
if (anim.ease) props.ease = anim.ease;
|
||||
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
if (anim.extras) {
|
||||
for (const [k, v] of Object.entries(anim.extras)) {
|
||||
entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
|
||||
}
|
||||
}
|
||||
const objCode = `{ ${entries.join(", ")} }`;
|
||||
const posCode = valueToCode(
|
||||
typeof anim.position === "number" ? anim.position : (anim.position ?? 0),
|
||||
);
|
||||
if (anim.method === "fromTo") {
|
||||
const fromEntries = Object.entries(anim.fromProperties ?? {}).map(
|
||||
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
|
||||
);
|
||||
const fromCode = `{ ${fromEntries.join(", ")} }`;
|
||||
return `${timelineVar}.fromTo(${selector}, ${fromCode}, ${objCode}, ${posCode});`;
|
||||
}
|
||||
return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`;
|
||||
}
|
||||
|
||||
export function updateAnimationInScript(
|
||||
@@ -397,41 +624,79 @@ export function updateAnimationInScript(
|
||||
animationId: string,
|
||||
updates: Partial<GsapAnimation>,
|
||||
): string {
|
||||
const parsed = parseGsapScript(script);
|
||||
if (isParseFailure(parsed)) return script;
|
||||
const updated = parsed.animations.map((anim) =>
|
||||
anim.id === animationId ? { ...anim, ...updates } : anim,
|
||||
);
|
||||
return serializeGsapAnimations(updated, parsed.timelineVar, {
|
||||
preamble: parsed.preamble,
|
||||
postamble: parsed.postamble,
|
||||
});
|
||||
let parsed: ParsedGsapAst;
|
||||
try {
|
||||
parsed = parseGsapAst(script);
|
||||
} catch {
|
||||
return script;
|
||||
}
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
applyUpdatesToCall(target.call, updates);
|
||||
return recast.print(parsed.ast).code;
|
||||
}
|
||||
|
||||
export function addAnimationToScript(
|
||||
script: string,
|
||||
animation: Omit<GsapAnimation, "id">,
|
||||
): { script: string; id: string } {
|
||||
const parsed = parseGsapScript(script);
|
||||
if (isParseFailure(parsed)) return { script, id: "" };
|
||||
let parsed: ParsedGsapAst;
|
||||
try {
|
||||
parsed = parseGsapAst(script);
|
||||
} catch {
|
||||
return { script, id: "" };
|
||||
}
|
||||
// Nothing to anchor against and no timeline to target — treat as parse failure.
|
||||
if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
|
||||
return { script, id: "" };
|
||||
}
|
||||
|
||||
const id = `anim-${Date.now()}`;
|
||||
const newAnim: GsapAnimation = { ...animation, id };
|
||||
const allAnimations = [...parsed.animations, newAnim];
|
||||
return {
|
||||
script: serializeGsapAnimations(allAnimations, parsed.timelineVar, {
|
||||
preamble: parsed.preamble,
|
||||
postamble: parsed.postamble,
|
||||
}),
|
||||
id,
|
||||
};
|
||||
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
|
||||
const newStatement = parseScript(statementCode).program.body[0];
|
||||
|
||||
const lastCall = parsed.located[parsed.located.length - 1]?.call;
|
||||
const anchorPath = lastCall
|
||||
? findStatementPath(lastCall.path)
|
||||
: findTimelineDeclarationPath(parsed.ast, parsed.timelineVar);
|
||||
|
||||
if (anchorPath) {
|
||||
anchorPath.insertAfter(newStatement);
|
||||
} else {
|
||||
parsed.ast.program.body.push(newStatement);
|
||||
}
|
||||
return { script: recast.print(parsed.ast).code, id };
|
||||
}
|
||||
|
||||
/** Find the statement path of `const <timelineVar> = gsap.timeline(...)`. */
|
||||
function findTimelineDeclarationPath(ast: any, timelineVar: string): any {
|
||||
let found: any = null;
|
||||
recast.types.visit(ast, {
|
||||
visitVariableDeclaration(path: any) {
|
||||
if (found) return false;
|
||||
for (const decl of path.node.declarations ?? []) {
|
||||
if (decl.id?.name === timelineVar && isGsapTimelineCall(decl.init)) {
|
||||
found = path;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.traverse(path);
|
||||
},
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
export function removeAnimationFromScript(script: string, animationId: string): string {
|
||||
const parsed = parseGsapScript(script);
|
||||
if (isParseFailure(parsed)) return script;
|
||||
const filtered = parsed.animations.filter((a) => a.id !== animationId);
|
||||
return serializeGsapAnimations(filtered, parsed.timelineVar, {
|
||||
preamble: parsed.preamble,
|
||||
postamble: parsed.postamble,
|
||||
});
|
||||
let parsed: ParsedGsapAst;
|
||||
try {
|
||||
parsed = parseGsapAst(script);
|
||||
} catch {
|
||||
return script;
|
||||
}
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
const stmtPath = findStatementPath(target.call.path);
|
||||
if (!stmtPath) return script;
|
||||
stmtPath.prune();
|
||||
return recast.print(parsed.ast).code;
|
||||
}
|
||||
|
||||
@@ -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,");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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