@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.
- package/dist/assets/index-CKJCBFsG.js +138 -0
- package/dist/assets/index-ZdgB8MFr.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/components/StudioFeedbackBar.tsx +208 -0
- package/src/components/StudioPreviewArea.tsx +97 -92
- package/src/components/StudioRightPanel.tsx +18 -0
- package/src/components/editor/AnimationCard.tsx +325 -0
- package/src/components/editor/EaseCurveSection.tsx +213 -0
- package/src/components/editor/GsapAnimationSection.tsx +112 -0
- package/src/components/editor/PropertyPanel.tsx +48 -18
- package/src/components/editor/domEditingTypes.ts +2 -0
- package/src/components/editor/gsapAnimationConstants.ts +130 -0
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/manualEdits.test.ts +101 -0
- package/src/components/editor/manualEdits.ts +22 -9
- package/src/components/editor/manualEditsDom.ts +22 -21
- package/src/components/editor/manualOffsetDrag.test.ts +35 -22
- package/src/components/editor/manualOffsetDrag.ts +1 -7
- package/src/components/editor/propertyPanelPrimitives.tsx +6 -1
- package/src/contexts/DomEditContext.tsx +27 -0
- package/src/hooks/useDomEditSession.ts +98 -2
- package/src/hooks/useDomSelection.ts +8 -0
- package/src/hooks/useGsapScriptCommits.ts +303 -0
- package/src/hooks/useGsapTweenCache.ts +80 -0
- package/src/hooks/usePreviewPersistence.ts +1 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +142 -0
- package/src/player/hooks/useTimelinePlayer.ts +2 -1
- package/src/telemetry/events.ts +32 -0
- package/dist/assets/index-Bvy50smZ.js +0 -138
- 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'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
|
+
});
|