@hyperframes/studio 0.6.52 → 0.6.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/assets/index-CKJCBFsG.js +138 -0
  2. package/dist/assets/index-ZdgB8MFr.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/components/StudioFeedbackBar.tsx +208 -0
  6. package/src/components/StudioPreviewArea.tsx +97 -92
  7. package/src/components/StudioRightPanel.tsx +18 -0
  8. package/src/components/editor/AnimationCard.tsx +325 -0
  9. package/src/components/editor/EaseCurveSection.tsx +213 -0
  10. package/src/components/editor/GsapAnimationSection.tsx +112 -0
  11. package/src/components/editor/PropertyPanel.tsx +48 -18
  12. package/src/components/editor/domEditingTypes.ts +2 -0
  13. package/src/components/editor/gsapAnimationConstants.ts +130 -0
  14. package/src/components/editor/manualEditingAvailability.ts +6 -0
  15. package/src/components/editor/manualEdits.test.ts +101 -0
  16. package/src/components/editor/manualEdits.ts +22 -9
  17. package/src/components/editor/manualEditsDom.ts +22 -21
  18. package/src/components/editor/manualOffsetDrag.test.ts +35 -22
  19. package/src/components/editor/manualOffsetDrag.ts +1 -7
  20. package/src/components/editor/propertyPanelPrimitives.tsx +6 -1
  21. package/src/contexts/DomEditContext.tsx +27 -0
  22. package/src/hooks/useDomEditSession.ts +98 -2
  23. package/src/hooks/useDomSelection.ts +8 -0
  24. package/src/hooks/useGsapScriptCommits.ts +303 -0
  25. package/src/hooks/useGsapTweenCache.ts +80 -0
  26. package/src/hooks/usePreviewPersistence.ts +1 -0
  27. package/src/player/hooks/useTimelinePlayer.seek.test.ts +142 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +2 -1
  29. package/src/telemetry/events.ts +32 -0
  30. package/dist/assets/index-Bvy50smZ.js +0 -138
  31. package/dist/assets/index-SKRp8mGz.css +0 -1
@@ -0,0 +1,325 @@
1
+ import { memo, useCallback, useMemo, useState } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
4
+ import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
5
+ import { MetricField, SelectField } from "./propertyPanelPrimitives";
6
+ import { controlPointsForGsapEase } from "./studioMotion";
7
+ import {
8
+ EASE_LABELS,
9
+ METHOD_LABELS,
10
+ METHOD_TOOLTIPS,
11
+ PROP_LABELS,
12
+ PROP_TOOLTIPS,
13
+ PROP_UNITS,
14
+ } from "./gsapAnimationConstants";
15
+ import { EaseCurveSection } from "./EaseCurveSection";
16
+
17
+ const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
18
+ function isPercentProp(prop: string): boolean {
19
+ return PERCENT_PROPS.has(prop);
20
+ }
21
+
22
+ function buildTweenSummary(animation: GsapAnimation): string {
23
+ const easeName = animation.ease ?? "none";
24
+ const ease = EASE_LABELS[easeName] ?? easeName;
25
+ const props = Object.entries(animation.properties);
26
+ const target = animation.targetSelector;
27
+ const dur = animation.duration ?? 0;
28
+ const pos = animation.position;
29
+ const propDescs = props.map(([p, v]) => {
30
+ const label = (PROP_LABELS[p] ?? p).toLowerCase();
31
+ const unit = PROP_UNITS[p] ?? "";
32
+ return `${label} to ${v}${unit}`;
33
+ });
34
+ const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet";
35
+ if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`;
36
+ if (animation.method === "from")
37
+ return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`;
38
+ return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
39
+ }
40
+
41
+ function parseNumericOrString(raw: string): number | string {
42
+ const num = Number(raw);
43
+ return Number.isFinite(num) ? num : raw;
44
+ }
45
+
46
+ interface AnimationCardProps {
47
+ animation: GsapAnimation;
48
+ defaultExpanded: boolean;
49
+ onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
50
+ onUpdateMeta: (
51
+ animationId: string,
52
+ updates: { duration?: number; ease?: string; position?: number },
53
+ ) => void;
54
+ onDeleteAnimation: (animationId: string) => void;
55
+ onAddProperty: (animationId: string, property: string) => void;
56
+ onRemoveProperty: (animationId: string, property: string) => void;
57
+ onLivePreview?: (property: string, value: number | string) => void;
58
+ onLivePreviewEnd?: () => void;
59
+ }
60
+
61
+ // fallow-ignore-next-line complexity
62
+ export const AnimationCard = memo(function AnimationCard({
63
+ animation,
64
+ defaultExpanded,
65
+ onUpdateProperty,
66
+ onUpdateMeta,
67
+ onDeleteAnimation,
68
+ onAddProperty,
69
+ onRemoveProperty,
70
+ onLivePreview,
71
+ onLivePreviewEnd,
72
+ }: AnimationCardProps) {
73
+ const [expanded, setExpanded] = useState(defaultExpanded);
74
+ const [addingProp, setAddingProp] = useState(false);
75
+
76
+ const usedProps = useMemo(
77
+ () => new Set(Object.keys(animation.properties)),
78
+ [animation.properties],
79
+ );
80
+ const availableProps = useMemo(
81
+ () => SUPPORTED_PROPS.filter((p) => !usedProps.has(p)),
82
+ [usedProps],
83
+ );
84
+
85
+ const commitProperty = useCallback(
86
+ (prop: string, raw: string) => {
87
+ const value = parseNumericOrString(raw);
88
+ onUpdateProperty(animation.id, prop, value);
89
+ onLivePreviewEnd?.();
90
+ },
91
+ [animation.id, onUpdateProperty, onLivePreviewEnd],
92
+ );
93
+
94
+ const scrubProperty = useCallback(
95
+ (prop: string, raw: string) => {
96
+ onLivePreview?.(prop, parseNumericOrString(raw));
97
+ },
98
+ [onLivePreview],
99
+ );
100
+
101
+ const commitDuration = useCallback(
102
+ (raw: string) => {
103
+ const num = Number(raw);
104
+ if (Number.isFinite(num) && num >= 0)
105
+ onUpdateMeta(animation.id, { duration: Math.max(0, num) });
106
+ },
107
+ [animation.id, onUpdateMeta],
108
+ );
109
+
110
+ const commitPosition = useCallback(
111
+ (raw: string) => {
112
+ const num = Number(raw);
113
+ if (Number.isFinite(num) && num >= 0)
114
+ onUpdateMeta(animation.id, { position: Math.max(0, num) });
115
+ },
116
+ [animation.id, onUpdateMeta],
117
+ );
118
+
119
+ const [copied, setCopied] = useState(false);
120
+
121
+ const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
122
+ const easeName = animation.ease ?? "none";
123
+ const easeLabel = easeName.startsWith("custom(")
124
+ ? "Custom curve"
125
+ : (EASE_LABELS[easeName] ?? easeName);
126
+ const endTime =
127
+ typeof animation.position === "number"
128
+ ? animation.position + (animation.duration ?? 0)
129
+ : animation.position;
130
+
131
+ const summary = useMemo(() => buildTweenSummary(animation), [animation]);
132
+
133
+ return (
134
+ <div className="border-b border-neutral-800 pb-3">
135
+ <button
136
+ type="button"
137
+ onClick={() => setExpanded((v) => !v)}
138
+ className="flex w-full items-center gap-2 py-1.5"
139
+ >
140
+ <span
141
+ className="rounded bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-400"
142
+ title={METHOD_TOOLTIPS[animation.method]}
143
+ >
144
+ {methodLabel}
145
+ </span>
146
+ <span className="text-[11px] font-medium text-neutral-400" title="When this effect plays">
147
+ {typeof animation.position === "number" ? `${animation.position}s` : animation.position} –{" "}
148
+ {typeof endTime === "number" ? `${endTime.toFixed(1)}s` : endTime}
149
+ </span>
150
+ <span className="ml-auto text-[10px] text-neutral-500" title={easeName}>
151
+ {easeLabel}
152
+ </span>
153
+ <svg
154
+ width="10"
155
+ height="10"
156
+ viewBox="0 0 10 10"
157
+ fill="currentColor"
158
+ className={`flex-shrink-0 text-neutral-500 transition-transform ${expanded ? "" : "-rotate-90"}`}
159
+ >
160
+ <path d="M2 3l3 4 3-4z" />
161
+ </svg>
162
+ </button>
163
+
164
+ {expanded && (
165
+ <div className="pt-2">
166
+ <div className="space-y-3">
167
+ <div className="flex items-start gap-2">
168
+ <p className="flex-1 text-[10px] leading-relaxed text-neutral-400 italic">
169
+ {summary}
170
+ </p>
171
+ <button
172
+ type="button"
173
+ onClick={() => {
174
+ void navigator.clipboard.writeText(summary);
175
+ setCopied(true);
176
+ setTimeout(() => setCopied(false), 1500);
177
+ }}
178
+ className="flex-shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
179
+ title="Copy description to clipboard — paste into agent prompts"
180
+ >
181
+ {copied ? "Copied" : "Copy"}
182
+ </button>
183
+ </div>
184
+ <div className={RESPONSIVE_GRID}>
185
+ {animation.method !== "set" && (
186
+ <MetricField
187
+ label="Length"
188
+ value={String(Math.max(0, animation.duration ?? 0))}
189
+ suffix="s"
190
+ tooltip="How long this effect lasts"
191
+ onCommit={commitDuration}
192
+ />
193
+ )}
194
+ <MetricField
195
+ label="Starts at"
196
+ value={
197
+ typeof animation.position === "string"
198
+ ? animation.position
199
+ : String(Math.max(0, animation.position))
200
+ }
201
+ suffix={typeof animation.position === "number" ? "s" : undefined}
202
+ tooltip="When this effect begins on the timeline"
203
+ onCommit={commitPosition}
204
+ />
205
+ </div>
206
+
207
+ {animation.method !== "set" && (
208
+ <>
209
+ <SelectField
210
+ label="Speed"
211
+ value={
212
+ animation.ease?.startsWith("custom(") ? "custom" : (animation.ease ?? "none")
213
+ }
214
+ options={[...SUPPORTED_EASES, "custom"]}
215
+ onChange={(next) => {
216
+ if (next === "custom") {
217
+ const points = controlPointsForGsapEase(animation.ease ?? "power2.out");
218
+ const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
219
+ onUpdateMeta(animation.id, { ease: `custom(${path})` });
220
+ } else {
221
+ onUpdateMeta(animation.id, { ease: next });
222
+ }
223
+ }}
224
+ />
225
+ <EaseCurveSection
226
+ ease={animation.ease ?? "none"}
227
+ duration={animation.duration}
228
+ onCustomEaseCommit={(customEase) =>
229
+ onUpdateMeta(animation.id, { ease: customEase })
230
+ }
231
+ />
232
+ </>
233
+ )}
234
+
235
+ {Object.keys(animation.properties).length > 0 && (
236
+ <div className="space-y-1.5">
237
+ {Object.entries(animation.properties).map(([prop, val]) => (
238
+ <div key={prop} className="flex items-center gap-1">
239
+ <div className="min-w-0 flex-1">
240
+ <MetricField
241
+ label={PROP_LABELS[prop] ?? prop}
242
+ value={
243
+ isPercentProp(prop) ? String(Math.round(Number(val) * 100)) : String(val)
244
+ }
245
+ suffix={PROP_UNITS[prop]}
246
+ tooltip={PROP_TOOLTIPS[prop]}
247
+ scrub
248
+ liveCommit
249
+ onCommit={(raw) => {
250
+ const adjusted = isPercentProp(prop) ? String(Number(raw) / 100) : raw;
251
+ scrubProperty(prop, adjusted);
252
+ commitProperty(prop, adjusted);
253
+ }}
254
+ />
255
+ </div>
256
+ <button
257
+ type="button"
258
+ onClick={() => onRemoveProperty(animation.id, prop)}
259
+ className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
260
+ title={`Remove ${PROP_LABELS[prop] ?? prop}`}
261
+ >
262
+ <svg
263
+ width="12"
264
+ height="12"
265
+ viewBox="0 0 12 12"
266
+ fill="none"
267
+ stroke="currentColor"
268
+ strokeWidth="1.5"
269
+ >
270
+ <path d="M3 3l6 6M9 3l-6 6" />
271
+ </svg>
272
+ </button>
273
+ </div>
274
+ ))}
275
+ </div>
276
+ )}
277
+
278
+ <div className="flex items-center gap-2 pt-1">
279
+ {addingProp && availableProps.length > 0 ? (
280
+ <select
281
+ autoFocus
282
+ className="min-w-0 rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-[11px] text-neutral-100 outline-none"
283
+ defaultValue=""
284
+ onChange={(e) => {
285
+ if (e.target.value) onAddProperty(animation.id, e.target.value);
286
+ setAddingProp(false);
287
+ }}
288
+ onBlur={() => setAddingProp(false)}
289
+ >
290
+ <option value="" disabled>
291
+ Choose effect…
292
+ </option>
293
+ {availableProps.map((p) => (
294
+ <option key={p} value={p}>
295
+ {PROP_LABELS[p] ?? p}
296
+ </option>
297
+ ))}
298
+ </select>
299
+ ) : (
300
+ availableProps.length > 0 && (
301
+ <button
302
+ type="button"
303
+ onClick={() => setAddingProp(true)}
304
+ className="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200"
305
+ title="Add another animated property to this effect"
306
+ >
307
+ + Effect
308
+ </button>
309
+ )
310
+ )}
311
+ <button
312
+ type="button"
313
+ onClick={() => onDeleteAnimation(animation.id)}
314
+ className="ml-auto text-[11px] font-medium text-red-400 transition-colors hover:text-red-300"
315
+ title="Remove this animation"
316
+ >
317
+ Remove
318
+ </button>
319
+ </div>
320
+ </div>
321
+ </div>
322
+ )}
323
+ </div>
324
+ );
325
+ });
@@ -0,0 +1,213 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
3
+
4
+ function round2(n: number): number {
5
+ return Math.round(n * 100) / 100;
6
+ }
7
+
8
+ export function EaseCurveSection({
9
+ ease,
10
+ duration,
11
+ onCustomEaseCommit,
12
+ }: {
13
+ ease: string;
14
+ duration?: number;
15
+ onCustomEaseCommit: (ease: string) => void;
16
+ }) {
17
+ const isCustom = ease.startsWith("custom(");
18
+ const curveFromPreset = EASE_CURVES[ease];
19
+ const customPoints = isCustom ? parseCustomEaseFromString(ease) : null;
20
+ const curve: [number, number, number, number] | null =
21
+ isCustom && customPoints
22
+ ? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2]
23
+ : (curveFromPreset ?? null);
24
+
25
+ const [draft, setDraft] = useState<[number, number, number, number] | null>(null);
26
+ const [progress, setProgress] = useState<number | null>(null);
27
+ const draggingRef = useRef<"p1" | "p2" | null>(null);
28
+ const svgRef = useRef<SVGSVGElement | null>(null);
29
+ const rafRef = useRef<number>(0);
30
+
31
+ const play = useCallback(() => {
32
+ const start = performance.now();
33
+ const dur = 1000;
34
+ const tick = (now: number) => {
35
+ const t = Math.min((now - start) / dur, 1);
36
+ setProgress(t);
37
+ if (t < 1) rafRef.current = requestAnimationFrame(tick);
38
+ else setTimeout(() => setProgress(null), 400);
39
+ };
40
+ cancelAnimationFrame(rafRef.current);
41
+ rafRef.current = requestAnimationFrame(tick);
42
+ }, []);
43
+
44
+ const active = draft ?? curve;
45
+ if (!active) return null;
46
+ const [x1, y1, x2, y2] = active;
47
+
48
+ const w = 200;
49
+ const h = 100;
50
+ const pad = 14;
51
+ const gw = w - pad * 2;
52
+ const gh = h - pad * 2;
53
+
54
+ const toSvg = (px: number, py: number) => ({
55
+ x: pad + gw * px,
56
+ y: h - pad - gh * py,
57
+ });
58
+
59
+ const curvePath = `M${pad},${h - pad} C${toSvg(x1, y1).x},${toSvg(x1, y1).y} ${toSvg(x2, y2).x},${toSvg(x2, y2).y} ${w - pad},${pad}`;
60
+
61
+ let dotX = pad;
62
+ let dotY = h - pad;
63
+ if (progress !== null) {
64
+ const t = progress;
65
+ const mt = 1 - t;
66
+ dotX = pad + gw * (mt * mt * mt * 0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t);
67
+ dotY =
68
+ h - pad - gh * (mt * mt * mt * 0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t);
69
+ }
70
+
71
+ const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
72
+ e.preventDefault();
73
+ e.stopPropagation();
74
+ draggingRef.current = handle;
75
+ (e.target as SVGElement).setPointerCapture(e.pointerId);
76
+ if (!draft) setDraft([x1, y1, x2, y2]);
77
+ };
78
+
79
+ const handlePointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
80
+ if (!draggingRef.current || !svgRef.current) return;
81
+ e.preventDefault();
82
+ const rect = svgRef.current.getBoundingClientRect();
83
+ const sx = ((e.clientX - rect.left) / rect.width) * w;
84
+ const sy = ((e.clientY - rect.top) / rect.height) * h;
85
+ const px = Math.max(0, Math.min(1, (sx - pad) / gw));
86
+ const py = Math.max(-1, Math.min(2, (h - pad - sy) / gh));
87
+ const prev = draft ?? [x1, y1, x2, y2];
88
+ const next: [number, number, number, number] =
89
+ draggingRef.current === "p1"
90
+ ? [round2(px), round2(py), prev[2], prev[3]]
91
+ : [prev[0], prev[1], round2(px), round2(py)];
92
+ setDraft(next);
93
+ };
94
+
95
+ const handlePointerUp = () => {
96
+ if (!draggingRef.current || !draft) return;
97
+ draggingRef.current = null;
98
+ const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`;
99
+ onCustomEaseCommit(`custom(${path})`);
100
+ setDraft(null);
101
+ };
102
+
103
+ const p1 = toSvg(x1, y1);
104
+ const p2 = toSvg(x2, y2);
105
+ const start = toSvg(0, 0);
106
+ const end = toSvg(1, 1);
107
+ const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease);
108
+
109
+ return (
110
+ <div className="rounded-lg bg-neutral-900/50 p-2">
111
+ <div className="mb-1.5 flex items-center justify-between">
112
+ <span className="text-[10px] font-medium text-neutral-500">Speed curve</span>
113
+ <button
114
+ type="button"
115
+ onClick={play}
116
+ className="rounded px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 transition-colors hover:bg-emerald-500/10"
117
+ >
118
+ {progress !== null ? "Playing…" : "Preview"}
119
+ </button>
120
+ </div>
121
+ <div className="overflow-hidden rounded pt-[72px] -mt-[72px]">
122
+ <svg
123
+ ref={svgRef}
124
+ width="100%"
125
+ height={h}
126
+ viewBox={`0 0 ${w} ${h}`}
127
+ preserveAspectRatio="none"
128
+ style={{ overflow: "visible" }}
129
+ className="touch-none select-none"
130
+ onPointerMove={handlePointerMove}
131
+ onPointerUp={handlePointerUp}
132
+ onPointerCancel={handlePointerUp}
133
+ >
134
+ <line
135
+ x1={pad}
136
+ y1={h - pad}
137
+ x2={w - pad}
138
+ y2={h - pad}
139
+ stroke="white"
140
+ strokeOpacity="0.06"
141
+ strokeWidth="0.5"
142
+ />
143
+ <line
144
+ x1={pad}
145
+ y1={pad}
146
+ x2={pad}
147
+ y2={h - pad}
148
+ stroke="white"
149
+ strokeOpacity="0.06"
150
+ strokeWidth="0.5"
151
+ />
152
+ <line
153
+ x1={start.x}
154
+ y1={start.y}
155
+ x2={p1.x}
156
+ y2={p1.y}
157
+ stroke="rgba(52,211,153,0.25)"
158
+ strokeWidth="1"
159
+ />
160
+ <line
161
+ x1={end.x}
162
+ y1={end.y}
163
+ x2={p2.x}
164
+ y2={p2.y}
165
+ stroke="rgba(52,211,153,0.25)"
166
+ strokeWidth="1"
167
+ />
168
+ <path d={curvePath} fill="none" stroke="#34d399" strokeWidth="2" strokeLinecap="round" />
169
+ {progress !== null && <circle cx={dotX} cy={dotY} r="4" fill="#34d399" />}
170
+ <circle
171
+ cx={p1.x}
172
+ cy={p1.y}
173
+ r="5"
174
+ fill="#0a0a1a"
175
+ stroke="#34d399"
176
+ strokeWidth="2"
177
+ className="cursor-grab active:cursor-grabbing"
178
+ onPointerDown={(e) => handlePointerDown("p1", e)}
179
+ />
180
+ <circle
181
+ cx={p2.x}
182
+ cy={p2.y}
183
+ r="5"
184
+ fill="#0a0a1a"
185
+ stroke="#34d399"
186
+ strokeWidth="2"
187
+ className="cursor-grab active:cursor-grabbing"
188
+ onPointerDown={(e) => handlePointerDown("p2", e)}
189
+ />
190
+ {duration != null && duration > 0 && (
191
+ <>
192
+ <text x={pad} y={h - 1} textAnchor="start" className="fill-neutral-600 text-[8px]">
193
+ 0s
194
+ </text>
195
+ <text
196
+ x={pad + gw / 2}
197
+ y={h - 1}
198
+ textAnchor="middle"
199
+ className="fill-neutral-600 text-[8px]"
200
+ >
201
+ {(duration / 2).toFixed(1)}s
202
+ </text>
203
+ <text x={w - pad} y={h - 1} textAnchor="end" className="fill-neutral-600 text-[8px]">
204
+ {duration}s
205
+ </text>
206
+ </>
207
+ )}
208
+ </svg>
209
+ </div>
210
+ <p className="mt-1 text-center text-[10px] text-neutral-500">{label}</p>
211
+ </div>
212
+ );
213
+ }
@@ -0,0 +1,112 @@
1
+ import { memo, useState } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import { Film } from "../../icons/SystemIcons";
4
+ import { Section } from "./propertyPanelPrimitives";
5
+ import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
6
+ import { AnimationCard } from "./AnimationCard";
7
+
8
+ interface GsapAnimationSectionProps {
9
+ animations: GsapAnimation[];
10
+ multipleTimelines?: boolean;
11
+ unsupportedTimelinePattern?: boolean;
12
+ onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
13
+ onUpdateMeta: (
14
+ animationId: string,
15
+ updates: { duration?: number; ease?: string; position?: number },
16
+ ) => void;
17
+ onDeleteAnimation: (animationId: string) => void;
18
+ onAddProperty: (animationId: string, property: string) => void;
19
+ onRemoveProperty: (animationId: string, property: string) => void;
20
+ onAddAnimation: (method: "to" | "from" | "set") => void;
21
+ onLivePreview?: (property: string, value: number | string) => void;
22
+ onLivePreviewEnd?: () => void;
23
+ }
24
+
25
+ export const GsapAnimationSection = memo(function GsapAnimationSection({
26
+ animations,
27
+ multipleTimelines,
28
+ unsupportedTimelinePattern,
29
+ onUpdateProperty,
30
+ onUpdateMeta,
31
+ onDeleteAnimation,
32
+ onAddProperty,
33
+ onRemoveProperty,
34
+ onAddAnimation,
35
+ onLivePreview,
36
+ onLivePreviewEnd,
37
+ }: GsapAnimationSectionProps) {
38
+ const [addMenuOpen, setAddMenuOpen] = useState(false);
39
+
40
+ return (
41
+ <Section title="Animation" icon={<Film size={15} />}>
42
+ {multipleTimelines && (
43
+ <p className="mb-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
44
+ This file has multiple GSAP timelines. Animation editing is disabled to prevent data loss
45
+ — consolidate into a single timeline to enable editing.
46
+ </p>
47
+ )}
48
+ {unsupportedTimelinePattern && (
49
+ <p className="mb-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
50
+ This composition uses a timeline assignment pattern (window.__timelines[...]) that the
51
+ editor doesn&apos;t support. Use a variable declaration (const tl = gsap.timeline()) to
52
+ enable editing.
53
+ </p>
54
+ )}
55
+ {multipleTimelines || unsupportedTimelinePattern ? null : (
56
+ <div className="space-y-2">
57
+ {animations.map((anim, index) => (
58
+ <AnimationCard
59
+ key={anim.id}
60
+ animation={anim}
61
+ defaultExpanded={index === 0}
62
+ onUpdateProperty={onUpdateProperty}
63
+ onUpdateMeta={onUpdateMeta}
64
+ onDeleteAnimation={onDeleteAnimation}
65
+ onAddProperty={onAddProperty}
66
+ onRemoveProperty={onRemoveProperty}
67
+ onLivePreview={onLivePreview}
68
+ onLivePreviewEnd={onLivePreviewEnd}
69
+ />
70
+ ))}
71
+
72
+ <div className="relative pt-1">
73
+ {addMenuOpen ? (
74
+ <div className="flex gap-1.5">
75
+ {ADD_METHODS.map((method) => (
76
+ <button
77
+ key={method}
78
+ type="button"
79
+ title={METHOD_TOOLTIPS[method]}
80
+ onClick={() => {
81
+ onAddAnimation(method);
82
+ setAddMenuOpen(false);
83
+ }}
84
+ 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"
85
+ >
86
+ {ADD_METHOD_LABELS[method] ?? method}
87
+ </button>
88
+ ))}
89
+ <button
90
+ type="button"
91
+ onClick={() => setAddMenuOpen(false)}
92
+ className="px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300"
93
+ >
94
+ Cancel
95
+ </button>
96
+ </div>
97
+ ) : (
98
+ <button
99
+ type="button"
100
+ onClick={() => setAddMenuOpen(true)}
101
+ className="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200"
102
+ title="Add a new animation effect to this element"
103
+ >
104
+ + Add effect
105
+ </button>
106
+ )}
107
+ </div>
108
+ </div>
109
+ )}
110
+ </Section>
111
+ );
112
+ });