@hyperframes/studio 0.5.5 → 0.6.0-alpha.2

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 (74) hide show
  1. package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
  2. package/dist/assets/index-UWFaHilT.css +1 -0
  3. package/dist/assets/index-cPJbxeAk.js +107 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2621 -170
  7. package/src/components/LintModal.tsx +3 -4
  8. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  9. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  10. package/src/components/editor/MotionPanel.tsx +651 -0
  11. package/src/components/editor/PropertyPanel.test.ts +67 -0
  12. package/src/components/editor/PropertyPanel.tsx +2891 -207
  13. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  14. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  15. package/src/components/editor/colorValue.test.ts +82 -0
  16. package/src/components/editor/colorValue.ts +175 -0
  17. package/src/components/editor/domEditing.test.ts +872 -0
  18. package/src/components/editor/domEditing.ts +993 -0
  19. package/src/components/editor/floatingPanel.test.ts +34 -0
  20. package/src/components/editor/floatingPanel.ts +54 -0
  21. package/src/components/editor/fontAssets.ts +32 -0
  22. package/src/components/editor/fontCatalog.ts +126 -0
  23. package/src/components/editor/gradientValue.test.ts +89 -0
  24. package/src/components/editor/gradientValue.ts +445 -0
  25. package/src/components/editor/manualEditingAvailability.test.ts +129 -0
  26. package/src/components/editor/manualEditingAvailability.ts +60 -0
  27. package/src/components/editor/manualEdits.test.ts +945 -0
  28. package/src/components/editor/manualEdits.ts +1397 -0
  29. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  30. package/src/components/editor/manualOffsetDrag.ts +307 -0
  31. package/src/components/editor/studioMotion.test.ts +355 -0
  32. package/src/components/editor/studioMotion.ts +632 -0
  33. package/src/components/nle/NLELayout.tsx +27 -4
  34. package/src/components/nle/NLEPreview.tsx +50 -5
  35. package/src/components/renders/RenderQueue.tsx +13 -62
  36. package/src/components/renders/useRenderQueue.ts +6 -30
  37. package/src/components/sidebar/AssetsTab.tsx +3 -4
  38. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  39. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  40. package/src/components/sidebar/LeftSidebar.tsx +140 -125
  41. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  42. package/src/hooks/usePersistentEditHistory.ts +337 -0
  43. package/src/icons/SystemIcons.tsx +2 -0
  44. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  45. package/src/player/components/CompositionThumbnail.tsx +50 -13
  46. package/src/player/components/EditModal.tsx +5 -20
  47. package/src/player/components/Player.tsx +18 -2
  48. package/src/player/components/Timeline.test.ts +20 -0
  49. package/src/player/components/Timeline.tsx +103 -21
  50. package/src/player/components/TimelineClip.test.ts +92 -0
  51. package/src/player/components/TimelineClip.tsx +241 -7
  52. package/src/player/components/timelineEditing.test.ts +16 -3
  53. package/src/player/components/timelineEditing.ts +10 -3
  54. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  55. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  56. package/src/player/store/playerStore.ts +2 -0
  57. package/src/utils/clipboard.test.ts +89 -0
  58. package/src/utils/clipboard.ts +57 -0
  59. package/src/utils/editHistory.test.ts +244 -0
  60. package/src/utils/editHistory.ts +218 -0
  61. package/src/utils/editHistoryStorage.test.ts +37 -0
  62. package/src/utils/editHistoryStorage.ts +99 -0
  63. package/src/utils/mediaTypes.ts +1 -1
  64. package/src/utils/sourcePatcher.test.ts +128 -1
  65. package/src/utils/sourcePatcher.ts +130 -18
  66. package/src/utils/studioFileHistory.test.ts +156 -0
  67. package/src/utils/studioFileHistory.ts +61 -0
  68. package/src/utils/timelineAssetDrop.test.ts +31 -11
  69. package/src/utils/timelineAssetDrop.ts +22 -2
  70. package/src/utils/timelineInspector.test.ts +79 -0
  71. package/src/utils/timelineInspector.ts +116 -0
  72. package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
  73. package/dist/assets/index-04Mp2wOn.css +0 -1
  74. package/dist/assets/index-960mgQMI.js +0 -93
@@ -0,0 +1,651 @@
1
+ import {
2
+ memo,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type PointerEvent,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { RotateCcw, X, Zap } from "../../icons/SystemIcons";
11
+ import type { DomEditSelection } from "./domEditing";
12
+ import {
13
+ STUDIO_GSAP_EASE_OPTIONS,
14
+ buildStudioGsapPresetMotion,
15
+ clampStudioCustomEasePoints,
16
+ controlPointsForGsapEase,
17
+ parseStudioCustomEaseData,
18
+ serializeStudioCustomEaseData,
19
+ type StudioCustomEaseControlPoints,
20
+ type StudioGsapMotion,
21
+ type StudioGsapMotionDirection,
22
+ type StudioGsapMotionPreset,
23
+ } from "./studioMotion";
24
+
25
+ interface MotionPanelProps {
26
+ element: DomEditSelection | null;
27
+ motion: StudioGsapMotion | null;
28
+ onClearSelection: () => void;
29
+ onSetMotion: (
30
+ element: DomEditSelection,
31
+ motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
32
+ ) => void;
33
+ onClearMotion: (element: DomEditSelection) => void;
34
+ }
35
+
36
+ const FIELD =
37
+ "min-w-0 rounded-xl border border-neutral-800 bg-neutral-900/95 px-3 py-2 text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] transition-colors focus-within:border-neutral-600";
38
+ const LABEL = "text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500";
39
+ const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
40
+
41
+ const MOTION_PRESET_OPTIONS: Array<{ label: string; value: StudioGsapMotionPreset }> = [
42
+ { label: "Fade Up", value: "fade-up" },
43
+ { label: "Slide", value: "slide" },
44
+ { label: "Pop", value: "pop" },
45
+ ];
46
+
47
+ const MOTION_DIRECTION_OPTIONS: StudioGsapMotionDirection[] = ["up", "down", "left", "right"];
48
+
49
+ function formatNumericValue(value: number): string {
50
+ const rounded = Math.round(value * 100) / 100;
51
+ return Number.isInteger(rounded)
52
+ ? `${rounded}`
53
+ : rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
54
+ }
55
+
56
+ function clampMotionNumber(
57
+ value: number | null,
58
+ min: number,
59
+ max: number,
60
+ fallback: number,
61
+ ): number {
62
+ if (value == null || !Number.isFinite(value)) return fallback;
63
+ return Math.min(max, Math.max(min, value));
64
+ }
65
+
66
+ function parsePlainNumber(value: string): number | null {
67
+ const parsed = Number.parseFloat(value.trim());
68
+ return Number.isFinite(parsed) ? parsed : null;
69
+ }
70
+
71
+ function motionValueDistance(motion: StudioGsapMotion | null): number {
72
+ if (!motion) return 32;
73
+ return Math.max(Math.abs(motion.from.x ?? 0), Math.abs(motion.from.y ?? 0), 1);
74
+ }
75
+
76
+ function inferMotionPreset(motion: StudioGsapMotion | null): StudioGsapMotionPreset {
77
+ if (!motion) return "fade-up";
78
+ if (motion.from.scale != null || motion.to.scale != null) return "pop";
79
+ if (motion.from.x != null || motion.to.x != null) return "slide";
80
+ return "fade-up";
81
+ }
82
+
83
+ function inferMotionDirection(motion: StudioGsapMotion | null): StudioGsapMotionDirection {
84
+ if (!motion) return "up";
85
+ const x = motion.from.x ?? 0;
86
+ const y = motion.from.y ?? 0;
87
+ if (Math.abs(x) > Math.abs(y)) return x < 0 ? "right" : "left";
88
+ return y < 0 ? "down" : "up";
89
+ }
90
+
91
+ function buildStudioCustomEaseId(element: DomEditSelection): string {
92
+ const source = element.id || element.selector || element.label || "layer";
93
+ const normalized = source
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9]+/g, "-")
96
+ .replace(/^-|-$/g, "");
97
+ return `studio-${normalized || "layer"}-ease`;
98
+ }
99
+
100
+ function CommitField({
101
+ value,
102
+ disabled,
103
+ onCommit,
104
+ }: {
105
+ value: string;
106
+ disabled?: boolean;
107
+ onCommit: (nextValue: string) => void;
108
+ }) {
109
+ const [draft, setDraft] = useState(value);
110
+ const focusedRef = useRef(false);
111
+
112
+ useEffect(() => {
113
+ if (!focusedRef.current) setDraft(value);
114
+ }, [value]);
115
+
116
+ const commitDraft = () => {
117
+ focusedRef.current = false;
118
+ const next = draft.trim();
119
+ if (next !== value) onCommit(next);
120
+ };
121
+
122
+ return (
123
+ <input
124
+ type="text"
125
+ value={draft}
126
+ disabled={disabled}
127
+ onFocus={() => {
128
+ focusedRef.current = true;
129
+ }}
130
+ onChange={(event) => setDraft(event.target.value)}
131
+ onBlur={commitDraft}
132
+ onKeyDown={(event) => {
133
+ if (event.key === "Enter") (event.target as HTMLInputElement).blur();
134
+ }}
135
+ className="w-full min-w-0 bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
136
+ />
137
+ );
138
+ }
139
+
140
+ function DetailField({
141
+ label,
142
+ value,
143
+ disabled,
144
+ onCommit,
145
+ }: {
146
+ label: string;
147
+ value: string;
148
+ disabled?: boolean;
149
+ onCommit: (nextValue: string) => void;
150
+ }) {
151
+ return (
152
+ <label className="grid min-w-0 gap-1.5">
153
+ <span className={LABEL}>{label}</span>
154
+ <div className={FIELD}>
155
+ <CommitField value={value} disabled={disabled} onCommit={onCommit} />
156
+ </div>
157
+ </label>
158
+ );
159
+ }
160
+
161
+ function SegmentedControl({
162
+ value,
163
+ options,
164
+ onChange,
165
+ }: {
166
+ value: string;
167
+ options: Array<{ label: string; value: string }>;
168
+ onChange: (value: string) => void;
169
+ }) {
170
+ return (
171
+ <div className="grid grid-cols-3 gap-1 rounded-2xl border border-neutral-800 bg-neutral-950 p-1">
172
+ {options.map((option) => (
173
+ <button
174
+ key={option.value}
175
+ type="button"
176
+ onClick={() => onChange(option.value)}
177
+ className={`h-9 rounded-xl text-[11px] font-semibold transition-colors ${
178
+ option.value === value
179
+ ? "bg-neutral-800 text-white shadow-sm"
180
+ : "text-neutral-500 hover:bg-neutral-900 hover:text-neutral-200"
181
+ }`}
182
+ >
183
+ {option.label}
184
+ </button>
185
+ ))}
186
+ </div>
187
+ );
188
+ }
189
+
190
+ function SelectField({
191
+ label,
192
+ value,
193
+ options,
194
+ onChange,
195
+ }: {
196
+ label: string;
197
+ value: string;
198
+ options: readonly string[];
199
+ onChange: (value: string) => void;
200
+ }) {
201
+ return (
202
+ <label className="grid min-w-0 gap-1.5">
203
+ <span className={LABEL}>{label}</span>
204
+ <div className={FIELD}>
205
+ <select
206
+ value={value}
207
+ onChange={(event) => onChange(event.target.value)}
208
+ className="w-full min-w-0 appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none"
209
+ >
210
+ {options.map((option) => (
211
+ <option key={option} value={option}>
212
+ {option}
213
+ </option>
214
+ ))}
215
+ </select>
216
+ </div>
217
+ </label>
218
+ );
219
+ }
220
+
221
+ function MotionSection({
222
+ title,
223
+ children,
224
+ accessory,
225
+ }: {
226
+ title: string;
227
+ children: ReactNode;
228
+ accessory?: ReactNode;
229
+ }) {
230
+ return (
231
+ <section className="border-b border-neutral-800 px-4 py-5">
232
+ <div className="mb-4 flex items-center justify-between gap-3">
233
+ <div className="flex items-center gap-3">
234
+ <Zap size={15} className="text-neutral-500" />
235
+ <h3 className="text-[11px] font-semibold uppercase tracking-[0.22em] text-neutral-300">
236
+ {title}
237
+ </h3>
238
+ </div>
239
+ {accessory}
240
+ </div>
241
+ {children}
242
+ </section>
243
+ );
244
+ }
245
+
246
+ function cubicBezierPoint(t: number, p1: StudioCustomEaseControlPoints): { x: number; y: number } {
247
+ const inv = 1 - t;
248
+ const inv2 = inv * inv;
249
+ const t2 = t * t;
250
+ return {
251
+ x: 3 * inv2 * t * p1.x1 + 3 * inv * t2 * p1.x2 + t2 * t,
252
+ y: 3 * inv2 * t * p1.y1 + 3 * inv * t2 * p1.y2 + t2 * t,
253
+ };
254
+ }
255
+
256
+ function buildCurvePath(
257
+ points: StudioCustomEaseControlPoints,
258
+ map: (point: { x: number; y: number }) => { x: number; y: number },
259
+ ): string {
260
+ const commands: string[] = [];
261
+ for (let index = 0; index <= 48; index += 1) {
262
+ const point = map(cubicBezierPoint(index / 48, points));
263
+ commands.push(`${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`);
264
+ }
265
+ return commands.join(" ");
266
+ }
267
+
268
+ function EaseCurveEditor({
269
+ points,
270
+ onCommit,
271
+ }: {
272
+ points: StudioCustomEaseControlPoints;
273
+ onCommit: (points: StudioCustomEaseControlPoints) => void;
274
+ }) {
275
+ const svgRef = useRef<SVGSVGElement | null>(null);
276
+ const [draft, setDraft] = useState(points);
277
+ const draggingRef = useRef<"p1" | "p2" | null>(null);
278
+
279
+ useEffect(() => {
280
+ setDraft(points);
281
+ }, [points]);
282
+
283
+ const width = 324;
284
+ const height = 214;
285
+ const plot = { left: 46, top: 24, width: 242, height: 146 };
286
+ const yMin = -0.4;
287
+ const yMax = 1.4;
288
+
289
+ const mapPoint = (point: { x: number; y: number }) => ({
290
+ x: plot.left + point.x * plot.width,
291
+ y: plot.top + ((yMax - point.y) / (yMax - yMin)) * plot.height,
292
+ });
293
+
294
+ const unmapPointer = (event: PointerEvent<SVGSVGElement>) => {
295
+ const rect = svgRef.current?.getBoundingClientRect();
296
+ if (!rect) return null;
297
+ const x = ((event.clientX - rect.left) / rect.width) * width;
298
+ const y = ((event.clientY - rect.top) / rect.height) * height;
299
+ return clampStudioCustomEasePoints({
300
+ x1: draggingRef.current === "p1" ? (x - plot.left) / plot.width : draft.x1,
301
+ y1:
302
+ draggingRef.current === "p1"
303
+ ? yMax - ((y - plot.top) / plot.height) * (yMax - yMin)
304
+ : draft.y1,
305
+ x2: draggingRef.current === "p2" ? (x - plot.left) / plot.width : draft.x2,
306
+ y2:
307
+ draggingRef.current === "p2"
308
+ ? yMax - ((y - plot.top) / plot.height) * (yMax - yMin)
309
+ : draft.y2,
310
+ });
311
+ };
312
+
313
+ const start = mapPoint({ x: 0, y: 0 });
314
+ const end = mapPoint({ x: 1, y: 1 });
315
+ const p1 = mapPoint({ x: draft.x1, y: draft.y1 });
316
+ const p2 = mapPoint({ x: draft.x2, y: draft.y2 });
317
+ const curvePath = buildCurvePath(draft, mapPoint);
318
+
319
+ const handlePointerMove = (event: PointerEvent<SVGSVGElement>) => {
320
+ if (!draggingRef.current) return;
321
+ event.preventDefault();
322
+ const next = unmapPointer(event);
323
+ if (next) setDraft(next);
324
+ };
325
+
326
+ const endDrag = () => {
327
+ if (!draggingRef.current) return;
328
+ draggingRef.current = null;
329
+ onCommit(draft);
330
+ };
331
+
332
+ const startDrag = (handle: "p1" | "p2", event: PointerEvent<SVGCircleElement>) => {
333
+ event.preventDefault();
334
+ event.stopPropagation();
335
+ draggingRef.current = handle;
336
+ event.currentTarget.setPointerCapture(event.pointerId);
337
+ };
338
+
339
+ return (
340
+ <div className="overflow-hidden rounded-2xl border border-neutral-800 bg-black/40">
341
+ <div className="flex items-center justify-between gap-3 border-b border-neutral-800 px-3 py-2">
342
+ <div>
343
+ <div className={LABEL}>CustomEase</div>
344
+ <div className="mt-1 font-mono text-[10px] text-neutral-500">
345
+ {serializeStudioCustomEaseData(draft)}
346
+ </div>
347
+ </div>
348
+ <button
349
+ type="button"
350
+ onClick={() => {
351
+ const reset = controlPointsForGsapEase("power3.out");
352
+ setDraft(reset);
353
+ onCommit(reset);
354
+ }}
355
+ className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-800 bg-neutral-950 px-3 text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-100"
356
+ >
357
+ <RotateCcw size={13} />
358
+ Reset
359
+ </button>
360
+ </div>
361
+ <svg
362
+ ref={svgRef}
363
+ viewBox={`0 0 ${width} ${height}`}
364
+ className="block w-full select-none touch-none"
365
+ onPointerMove={handlePointerMove}
366
+ onPointerUp={endDrag}
367
+ onPointerCancel={endDrag}
368
+ >
369
+ <rect x="0" y="0" width={width} height={height} fill="transparent" />
370
+ {[0, 0.5, 1].map((value) => {
371
+ const mapped = mapPoint({ x: 0, y: value });
372
+ return (
373
+ <g key={value}>
374
+ <line
375
+ x1={plot.left}
376
+ x2={plot.left + plot.width}
377
+ y1={mapped.y}
378
+ y2={mapped.y}
379
+ stroke="rgba(255,255,255,0.12)"
380
+ strokeDasharray="5 8"
381
+ />
382
+ <text
383
+ x={plot.left - 12}
384
+ y={mapped.y + 4}
385
+ textAnchor="end"
386
+ className="fill-neutral-500 text-[10px] font-semibold"
387
+ >
388
+ {value}
389
+ </text>
390
+ </g>
391
+ );
392
+ })}
393
+ <line
394
+ x1={plot.left}
395
+ x2={plot.left + plot.width}
396
+ y1={plot.top + plot.height}
397
+ y2={plot.top + plot.height}
398
+ stroke="rgba(255,255,255,0.18)"
399
+ />
400
+ <line
401
+ x1={plot.left}
402
+ x2={plot.left}
403
+ y1={plot.top}
404
+ y2={plot.top + plot.height}
405
+ stroke="rgba(255,255,255,0.18)"
406
+ />
407
+ <line x1={start.x} y1={start.y} x2={p1.x} y2={p1.y} stroke="rgba(255,221,87,0.34)" />
408
+ <line x1={end.x} y1={end.y} x2={p2.x} y2={p2.y} stroke="rgba(255,221,87,0.34)" />
409
+ <path d={curvePath} fill="none" stroke="#ffdd57" strokeWidth="4" strokeLinecap="round" />
410
+ <circle cx={start.x} cy={start.y} r="5" fill="#ffdd57" />
411
+ <circle cx={end.x} cy={end.y} r="5" fill="#ffdd57" />
412
+ <circle
413
+ cx={p1.x}
414
+ cy={p1.y}
415
+ r="9"
416
+ fill="#141414"
417
+ stroke="#ffdd57"
418
+ strokeWidth="4"
419
+ className="cursor-grab active:cursor-grabbing"
420
+ onPointerDown={(event) => startDrag("p1", event)}
421
+ />
422
+ <circle
423
+ cx={p2.x}
424
+ cy={p2.y}
425
+ r="9"
426
+ fill="#141414"
427
+ stroke="#ffdd57"
428
+ strokeWidth="4"
429
+ className="cursor-grab active:cursor-grabbing"
430
+ onPointerDown={(event) => startDrag("p2", event)}
431
+ />
432
+ <text x={p1.x + 12} y={p1.y - 10} className="fill-neutral-400 text-[10px] font-semibold">
433
+ P1
434
+ </text>
435
+ <text x={p2.x + 12} y={p2.y - 10} className="fill-neutral-400 text-[10px] font-semibold">
436
+ P2
437
+ </text>
438
+ </svg>
439
+ <div className="grid grid-cols-2 gap-2 border-t border-neutral-800 p-3">
440
+ <div className="rounded-xl border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-[10px] text-neutral-400">
441
+ P1 {formatNumericValue(draft.x1)}, {formatNumericValue(draft.y1)}
442
+ </div>
443
+ <div className="rounded-xl border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-[10px] text-neutral-400">
444
+ P2 {formatNumericValue(draft.x2)}, {formatNumericValue(draft.y2)}
445
+ </div>
446
+ </div>
447
+ </div>
448
+ );
449
+ }
450
+
451
+ export const MotionPanel = memo(function MotionPanel({
452
+ element,
453
+ motion,
454
+ onClearSelection,
455
+ onSetMotion,
456
+ onClearMotion,
457
+ }: MotionPanelProps) {
458
+ const activeMotionPreset = inferMotionPreset(motion);
459
+ const activeMotionDirection = inferMotionDirection(motion);
460
+ const activeMotionStart = motion?.start ?? 0;
461
+ const activeMotionDuration = motion?.duration ?? 0.6;
462
+ const activeMotionDistance = motionValueDistance(motion);
463
+ const activeMotionEase = motion?.ease ?? "power3.out";
464
+ const customEaseData = motion?.customEase?.data ?? "";
465
+ const customEaseActive = customEaseData.trim().length > 0;
466
+ const activeCustomEasePoints = useMemo(
467
+ () =>
468
+ parseStudioCustomEaseData(customEaseData) ??
469
+ controlPointsForGsapEase(
470
+ STUDIO_GSAP_EASE_OPTIONS.includes(
471
+ activeMotionEase as (typeof STUDIO_GSAP_EASE_OPTIONS)[number],
472
+ )
473
+ ? activeMotionEase
474
+ : "power3.out",
475
+ ),
476
+ [activeMotionEase, customEaseData],
477
+ );
478
+
479
+ if (!element) {
480
+ return (
481
+ <div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
482
+ <Zap size={18} className="mb-3 text-neutral-600" />
483
+ <p className="text-sm font-medium text-neutral-200">Select an element for motion.</p>
484
+ <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
485
+ Timeline layers and inspector selections can receive Studio-authored GSAP motion.
486
+ </p>
487
+ </div>
488
+ );
489
+ }
490
+
491
+ const sourceLabel = element.id ? `#${element.id}` : element.selector;
492
+ const easeSelectValue = customEaseActive
493
+ ? "CustomEase"
494
+ : STUDIO_GSAP_EASE_OPTIONS.includes(
495
+ activeMotionEase as (typeof STUDIO_GSAP_EASE_OPTIONS)[number],
496
+ )
497
+ ? activeMotionEase
498
+ : "power3.out";
499
+ const easeSelectOptions = customEaseActive
500
+ ? ["CustomEase", ...STUDIO_GSAP_EASE_OPTIONS]
501
+ : STUDIO_GSAP_EASE_OPTIONS;
502
+
503
+ const commitMotion = (
504
+ overrides: Partial<{
505
+ preset: StudioGsapMotionPreset;
506
+ direction: StudioGsapMotionDirection;
507
+ start: number;
508
+ duration: number;
509
+ distance: number;
510
+ ease: string;
511
+ customEaseData: string;
512
+ }>,
513
+ ) => {
514
+ const customEaseText = overrides.customEaseData ?? customEaseData;
515
+ const customEase = customEaseText.trim()
516
+ ? {
517
+ id: motion?.customEase?.id ?? buildStudioCustomEaseId(element),
518
+ data: customEaseText.trim(),
519
+ }
520
+ : undefined;
521
+ const nextEase = customEase
522
+ ? customEase.id
523
+ : (overrides.ease ?? activeMotionEase).trim() || "none";
524
+ onSetMotion(
525
+ element,
526
+ buildStudioGsapPresetMotion(overrides.preset ?? activeMotionPreset, {
527
+ start: clampMotionNumber(overrides.start ?? activeMotionStart, 0, 3600, 0),
528
+ duration: clampMotionNumber(overrides.duration ?? activeMotionDuration, 0.01, 3600, 0.6),
529
+ distance: clampMotionNumber(overrides.distance ?? activeMotionDistance, 1, 2000, 32),
530
+ direction: overrides.direction ?? activeMotionDirection,
531
+ ease: nextEase,
532
+ customEase,
533
+ }),
534
+ );
535
+ };
536
+
537
+ const commitCustomEase = (points: StudioCustomEaseControlPoints) => {
538
+ commitMotion({
539
+ ease: buildStudioCustomEaseId(element),
540
+ customEaseData: serializeStudioCustomEaseData(points),
541
+ });
542
+ };
543
+
544
+ return (
545
+ <div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900 text-neutral-100">
546
+ <div className="border-b border-neutral-800 px-4 py-5">
547
+ <div className="flex items-start justify-between gap-4">
548
+ <div className="min-w-0">
549
+ <div className={LABEL}>Motion Target</div>
550
+ <div className="mt-3 truncate text-[12px] font-semibold text-neutral-100">
551
+ {element.label}
552
+ </div>
553
+ <div className="mt-1 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
554
+ </div>
555
+ <button
556
+ type="button"
557
+ aria-label="Clear selection"
558
+ onClick={onClearSelection}
559
+ className="flex h-9 w-9 items-center justify-center rounded-full border border-neutral-700 bg-neutral-950 text-neutral-500 shadow-[0_1px_2px_rgba(0,0,0,0.2)] transition-colors hover:border-neutral-600 hover:text-neutral-200"
560
+ >
561
+ <X size={13} />
562
+ </button>
563
+ </div>
564
+ </div>
565
+
566
+ <div className="flex-1 overflow-y-auto">
567
+ <MotionSection
568
+ title="GSAP Motion"
569
+ accessory={
570
+ <div className="rounded-full border border-studio-accent/40 bg-studio-accent/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-studio-accent">
571
+ GSAP
572
+ </div>
573
+ }
574
+ >
575
+ <div className="space-y-4">
576
+ <SegmentedControl
577
+ value={activeMotionPreset}
578
+ onChange={(next) => commitMotion({ preset: next as StudioGsapMotionPreset })}
579
+ options={MOTION_PRESET_OPTIONS}
580
+ />
581
+ <div className={RESPONSIVE_GRID}>
582
+ <SelectField
583
+ label="Direction"
584
+ value={activeMotionDirection}
585
+ onChange={(next) => commitMotion({ direction: next as StudioGsapMotionDirection })}
586
+ options={MOTION_DIRECTION_OPTIONS}
587
+ />
588
+ <SelectField
589
+ label="Ease"
590
+ value={easeSelectValue}
591
+ onChange={(next) => {
592
+ if (next === "CustomEase") return;
593
+ commitMotion({ ease: next, customEaseData: "" });
594
+ }}
595
+ options={easeSelectOptions}
596
+ />
597
+ </div>
598
+ <div className={RESPONSIVE_GRID}>
599
+ <DetailField
600
+ label="Start"
601
+ value={formatNumericValue(activeMotionStart)}
602
+ onCommit={(next) => commitMotion({ start: parsePlainNumber(next) ?? 0 })}
603
+ />
604
+ <DetailField
605
+ label="Duration"
606
+ value={formatNumericValue(activeMotionDuration)}
607
+ onCommit={(next) => commitMotion({ duration: parsePlainNumber(next) ?? 0.6 })}
608
+ />
609
+ <DetailField
610
+ label="Distance"
611
+ value={formatNumericValue(activeMotionDistance)}
612
+ onCommit={(next) => commitMotion({ distance: parsePlainNumber(next) ?? 32 })}
613
+ />
614
+ </div>
615
+ </div>
616
+ </MotionSection>
617
+
618
+ <MotionSection
619
+ title="Ease Curve"
620
+ accessory={
621
+ <div className="rounded-full border border-yellow-400/30 bg-yellow-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-yellow-300">
622
+ CustomEase
623
+ </div>
624
+ }
625
+ >
626
+ <div className="space-y-4">
627
+ <EaseCurveEditor points={activeCustomEasePoints} onCommit={commitCustomEase} />
628
+ <DetailField
629
+ label="CustomEase path"
630
+ value={customEaseData}
631
+ onCommit={(next) => {
632
+ const parsed = parseStudioCustomEaseData(next);
633
+ if (parsed) commitCustomEase(parsed);
634
+ }}
635
+ />
636
+ <div className="flex justify-end">
637
+ <button
638
+ type="button"
639
+ onClick={() => onClearMotion(element)}
640
+ disabled={!motion}
641
+ className="inline-flex h-8 items-center rounded-xl border border-neutral-700 bg-neutral-950 px-3 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:border-neutral-800 disabled:text-neutral-600"
642
+ >
643
+ Clear motion
644
+ </button>
645
+ </div>
646
+ </div>
647
+ </MotionSection>
648
+ </div>
649
+ </div>
650
+ );
651
+ });