feat(studio): GSAP tween editing in Design panel (#1102)

* feat(studio): GSAP tween editing in Design panel

Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.

Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.

recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:

- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
  conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
  reachable only via the @hyperframes/core/gsap-parser subpath, loaded
  server-side by the studio-api mutation routes and the linter via dynamic
  import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
  bundles never trace recast.

Adds AST parser unit + stress coverage and e2e helpers for the panel.

* fix(lint): await async lintHyperframeHtml in all callers

lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.

Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
This commit is contained in:
Miguel Ángel
2026-05-28 19:16:34 -04:00
committed by GitHub
parent e16f916448
commit fb2e21090f
61 changed files with 4354 additions and 1128 deletions
@@ -0,0 +1,112 @@
import { memo, useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { Film } from "../../icons/SystemIcons";
import { Section } from "./propertyPanelPrimitives";
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
import { AnimationCard } from "./AnimationCard";
interface GsapAnimationSectionProps {
animations: GsapAnimation[];
multipleTimelines?: boolean;
unsupportedTimelinePattern?: boolean;
onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
onUpdateMeta: (
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => void;
onDeleteAnimation: (animationId: string) => void;
onAddProperty: (animationId: string, property: string) => void;
onRemoveProperty: (animationId: string, property: string) => void;
onAddAnimation: (method: "to" | "from" | "set") => void;
onLivePreview?: (property: string, value: number | string) => void;
onLivePreviewEnd?: () => void;
}
export const GsapAnimationSection = memo(function GsapAnimationSection({
animations,
multipleTimelines,
unsupportedTimelinePattern,
onUpdateProperty,
onUpdateMeta,
onDeleteAnimation,
onAddProperty,
onRemoveProperty,
onAddAnimation,
onLivePreview,
onLivePreviewEnd,
}: GsapAnimationSectionProps) {
const [addMenuOpen, setAddMenuOpen] = useState(false);
return (
<Section title="Animation" icon={<Film size={15} />}>
{multipleTimelines && (
<p className="mb-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
This file has multiple GSAP timelines. Animation editing is disabled to prevent data loss
consolidate into a single timeline to enable editing.
</p>
)}
{unsupportedTimelinePattern && (
<p className="mb-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
This composition uses a timeline assignment pattern (window.__timelines[...]) that the
editor doesn&apos;t support. Use a variable declaration (const tl = gsap.timeline()) to
enable editing.
</p>
)}
{multipleTimelines || unsupportedTimelinePattern ? null : (
<div className="space-y-2">
{animations.map((anim, index) => (
<AnimationCard
key={anim.id}
animation={anim}
defaultExpanded={index === 0}
onUpdateProperty={onUpdateProperty}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={onAddProperty}
onRemoveProperty={onRemoveProperty}
onLivePreview={onLivePreview}
onLivePreviewEnd={onLivePreviewEnd}
/>
))}
<div className="relative pt-1">
{addMenuOpen ? (
<div className="flex gap-1.5">
{ADD_METHODS.map((method) => (
<button
key={method}
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
onAddAnimation(method);
setAddMenuOpen(false);
}}
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
>
{ADD_METHOD_LABELS[method] ?? method}
</button>
))}
<button
type="button"
onClick={() => setAddMenuOpen(false)}
className="px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setAddMenuOpen(true)}
className="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200"
title="Add a new animation effect to this element"
>
+ Add effect
</button>
)}
</div>
</div>
)}
</Section>
);
});