@hyperframes/studio 0.7.71 → 0.7.73

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 (79) hide show
  1. package/dist/assets/{hyperframes-player-C1X90psg.js → hyperframes-player-DGlzhf_s.js} +1 -1
  2. package/dist/assets/index-B37rWXo4.css +1 -0
  3. package/dist/assets/{index-DzxDHPR1.js → index-CbVTOJ-E.js} +1 -1
  4. package/dist/assets/{index-nY4lLBqM.js → index-CcA5WVJv.js} +1 -1
  5. package/dist/assets/index-DXcLI2wu.js +428 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7762 -4261
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +4 -4
  12. package/src/components/StudioLeftSidebar.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +10 -4
  14. package/src/components/editor/AnimationCard.test.tsx +100 -0
  15. package/src/components/editor/AnimationCard.tsx +5 -2
  16. package/src/components/editor/EaseCurveSection.test.tsx +348 -0
  17. package/src/components/editor/EaseCurveSection.tsx +444 -237
  18. package/src/components/editor/EaseParamFields.test.tsx +188 -0
  19. package/src/components/editor/EaseParamFields.tsx +246 -0
  20. package/src/components/editor/PropertyPanel.test.tsx +9 -7
  21. package/src/components/editor/PropertyPanelFlat.tsx +75 -88
  22. package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
  23. package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
  24. package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
  25. package/src/components/editor/easeCurveSvg.tsx +65 -0
  26. package/src/components/editor/easePresetLibrary.test.ts +114 -0
  27. package/src/components/editor/easePresetLibrary.ts +49 -0
  28. package/src/components/editor/gsapAnimationConstants.ts +15 -13
  29. package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
  30. package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
  31. package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
  32. package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
  33. package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
  34. package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
  35. package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
  36. package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
  37. package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
  38. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
  39. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +239 -41
  40. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +260 -300
  41. package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
  42. package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
  43. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
  44. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
  45. package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
  46. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
  47. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
  48. package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
  49. package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
  50. package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
  51. package/src/components/editor/propertyPanelTypes.ts +13 -0
  52. package/src/components/editor/useColorGradingController.test.ts +331 -10
  53. package/src/components/editor/useColorGradingController.ts +57 -90
  54. package/src/components/editor/useColorGradingPreviews.ts +350 -0
  55. package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
  56. package/src/components/editor/useColorGradingScopes.ts +62 -0
  57. package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
  58. package/src/components/nle/NLEContext.tsx +1 -1
  59. package/src/contexts/PanelLayoutContext.tsx +3 -6
  60. package/src/hooks/useBlockCatalog.ts +31 -13
  61. package/src/hooks/useBlockHandlers.ts +22 -0
  62. package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
  63. package/src/hooks/useGsapScriptCommits.ts +14 -1
  64. package/src/hooks/usePanelLayout.test.ts +68 -1
  65. package/src/hooks/usePanelLayout.ts +72 -34
  66. package/src/icons/SystemIcons.tsx +4 -0
  67. package/src/player/components/PlayerControls.tsx +68 -229
  68. package/src/player/components/ShortcutsPanel.tsx +2 -4
  69. package/src/player/components/SpeedMenu.tsx +1 -2
  70. package/src/telemetry/events.test.ts +9 -0
  71. package/src/telemetry/events.ts +5 -0
  72. package/src/utils/blockInstaller.test.ts +67 -0
  73. package/src/utils/blockInstaller.ts +162 -109
  74. package/src/utils/studioUiPreferences.test.ts +5 -0
  75. package/src/utils/studioUiPreferences.ts +8 -0
  76. package/dist/assets/index-CSCh2Vrp.js +0 -428
  77. package/dist/assets/index-Ceyz8Qt2.css +0 -1
  78. package/src/player/components/PlayerControls.test.ts +0 -20
  79. package/src/player/components/useSeekBarDrag.ts +0 -169
@@ -1,75 +1,55 @@
1
- import { useCallback, useRef, useState } from "react";
2
- import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
1
+ import { useEffect, useId, useRef, useState } from "react";
2
+ import { evaluateSpringEase, parseSpringBounce } from "@hyperframes/core/spring-ease";
3
+ import {
4
+ evaluateWiggleEase,
5
+ parseWiggleEase,
6
+ type WiggleEaseConfig,
7
+ } from "@hyperframes/core/wiggle-ease";
8
+ import { EASE_PRESETS, easePresetLabel } from "./easePresetLibrary";
9
+ import { holdCurvePath, MiniCurveSvg, sampledPath } from "./easeCurveSvg";
10
+ import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields";
11
+ import { EASE_CURVES, EASE_LABELS, resolveEaseCurveTuple } from "./gsapAnimationConstants";
3
12
  import { roundToCenti } from "../../utils/rounding";
4
13
 
5
- // Figma-canonical ordering: linear, the three core eases, then the expressive
6
- // (back / snappy) family. Each maps to a GSAP ease so it round-trips cleanly.
7
- const PRESET_GRID_EASES = [
8
- "none",
9
- "power2.in",
10
- "power2.out",
11
- "power2.inOut",
12
- "back.in",
13
- "back.out",
14
- "back.inOut",
15
- "expo.out",
16
- ] as const;
14
+ export { MiniCurveSvg } from "./easeCurveSvg";
17
15
 
18
- function MiniCurveSvg({
19
- curve,
20
- active,
21
- }: {
22
- curve: [number, number, number, number];
23
- active: boolean;
24
- }) {
25
- const [x1, y1, x2, y2] = curve;
26
- const s = 24;
27
- const p = 3;
28
- const g = s - p * 2;
29
- const sx = (px: number) => p + g * px;
30
- const sy = (py: number) => s - p - g * py;
31
- const d = `M${p},${s - p} C${sx(x1)},${sy(y1)} ${sx(x2)},${sy(y2)} ${s - p},${p}`;
32
- return (
33
- <svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
34
- <path
35
- d={d}
36
- fill="none"
37
- stroke={active ? "#3CE6AC" : "#737373"}
38
- strokeWidth="1.5"
39
- strokeLinecap="round"
40
- />
41
- </svg>
42
- );
43
- }
16
+ const EASE_MODES = ["curve", "spring", "wiggle"] as const;
17
+ type EaseMode = (typeof EASE_MODES)[number];
44
18
 
45
19
  const EasePresetGrid = function EasePresetGrid({
20
+ kind,
46
21
  currentEase,
47
22
  onSelect,
48
23
  }: {
24
+ kind: EaseMode;
49
25
  currentEase: string;
50
26
  onSelect: (ease: string) => void;
51
27
  }) {
52
28
  return (
53
- <div className="grid grid-cols-4 gap-1 mb-2">
54
- {PRESET_GRID_EASES.map((name) => {
55
- const curve = EASE_CURVES[name];
56
- if (!curve) return null;
57
- const isActive = currentEase === name;
29
+ <div className="mb-2 grid max-h-56 grid-cols-4 gap-1 overflow-y-auto pr-0.5">
30
+ {EASE_PRESETS.filter((preset) => preset.kind === kind).map((preset) => {
31
+ const isActive = currentEase === preset.ease;
58
32
  return (
59
33
  <button
60
- key={name}
34
+ key={preset.id}
61
35
  type="button"
62
- onClick={() => onSelect(name)}
36
+ data-ease-preset-id={preset.id}
37
+ role="menuitemradio"
38
+ aria-checked={isActive}
39
+ tabIndex={isActive ? 0 : -1}
40
+ onClick={() => onSelect(preset.ease)}
63
41
  className={`flex flex-col items-center gap-0.5 rounded-md p-1 transition-colors ${
64
42
  isActive ? "bg-panel-accent/10 ring-1 ring-panel-accent/30" : "hover:bg-neutral-800"
65
43
  }`}
66
- title={EASE_LABELS[name] ?? name}
44
+ title={preset.label}
67
45
  >
68
- <MiniCurveSvg curve={curve} active={isActive} />
46
+ <MiniCurveSvg ease={preset.ease} active={isActive} />
69
47
  <span
70
- className={`text-[8px] leading-none ${isActive ? "text-panel-accent" : "text-neutral-500"}`}
48
+ className={`text-center text-[8px] leading-none ${
49
+ isActive ? "text-panel-accent" : "text-neutral-500"
50
+ }`}
71
51
  >
72
- {(EASE_LABELS[name] ?? name).split(" ").slice(0, 2).join(" ")}
52
+ {preset.label}
73
53
  </span>
74
54
  </button>
75
55
  );
@@ -101,56 +81,276 @@ const DRAG_VMIN = -1;
101
81
  const ACCENT = "#3CE6AC";
102
82
 
103
83
  type Pts = [number, number, number, number];
84
+ const DEFAULT_CURVE: Pts = EASE_CURVES["power2.out"];
85
+ const MODE_LABELS = { curve: "Curve", spring: "Spring", wiggle: "Wiggle" } satisfies Record<
86
+ EaseMode,
87
+ string
88
+ >;
89
+ const DEFAULT_EASE_BY_MODE = {
90
+ curve: `custom(M0,0 C${DEFAULT_CURVE[0]},${DEFAULT_CURVE[1]} ${DEFAULT_CURVE[2]},${DEFAULT_CURVE[3]} 1,1)`,
91
+ spring: "spring(0.42)",
92
+ wiggle: "wiggle(3,easeInOut,0.12)",
93
+ } satisfies Record<EaseMode, string>;
94
+
95
+ function EaseModeToggle({ mode, onCommit }: { mode: EaseMode; onCommit: (ease: string) => void }) {
96
+ return (
97
+ <div
98
+ className="mb-2 grid grid-cols-3 rounded-md bg-black/20 p-0.5"
99
+ role="radiogroup"
100
+ aria-label="Ease editor mode"
101
+ >
102
+ {EASE_MODES.map((candidateMode) => {
103
+ const active = candidateMode === mode;
104
+ return (
105
+ <button
106
+ key={candidateMode}
107
+ type="button"
108
+ data-ease-mode={candidateMode}
109
+ role="radio"
110
+ aria-checked={active}
111
+ onClick={() => {
112
+ if (active) return;
113
+ onCommit(DEFAULT_EASE_BY_MODE[candidateMode]);
114
+ }}
115
+ className={`rounded px-2 py-1 text-[10px] font-medium transition-colors ${
116
+ active ? "bg-neutral-700 text-neutral-100" : "text-neutral-500 hover:text-neutral-300"
117
+ }`}
118
+ >
119
+ {MODE_LABELS[candidateMode]}
120
+ </button>
121
+ );
122
+ })}
123
+ </div>
124
+ );
125
+ }
126
+
127
+ // Figma-style ease-type dropdown: the current ease (glyph + name) as a button
128
+ // that opens the preset grid in a popover. This is where a preset is selected —
129
+ // the grid is no longer shown inline.
130
+ function EaseTypeDropdown({
131
+ kind,
132
+ ease,
133
+ label,
134
+ onSelect,
135
+ }: {
136
+ kind: EaseMode;
137
+ ease: string;
138
+ label: string;
139
+ onSelect: (ease: string) => void;
140
+ }) {
141
+ const [open, setOpen] = useState(false);
142
+ const triggerRef = useRef<HTMLButtonElement>(null);
143
+ const menuRef = useRef<HTMLDivElement>(null);
144
+ const menuId = useId();
145
+
146
+ useEffect(() => {
147
+ if (!open) return;
148
+ const menu = menuRef.current;
149
+ const selected =
150
+ menu?.querySelector<HTMLButtonElement>('[role="menuitemradio"][aria-checked="true"]') ??
151
+ menu?.querySelector<HTMLButtonElement>('[role="menuitemradio"]');
152
+ selected?.focus();
153
+
154
+ const closeOutside = (event: PointerEvent) => {
155
+ const target = event.target;
156
+ if (!(target instanceof Node)) return;
157
+ if (!menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) {
158
+ setOpen(false);
159
+ }
160
+ };
161
+ const closeOnEscape = (event: KeyboardEvent) => {
162
+ if (event.key !== "Escape") return;
163
+ event.preventDefault();
164
+ setOpen(false);
165
+ triggerRef.current?.focus();
166
+ };
167
+ document.addEventListener("pointerdown", closeOutside);
168
+ document.addEventListener("keydown", closeOnEscape);
169
+ return () => {
170
+ document.removeEventListener("pointerdown", closeOutside);
171
+ document.removeEventListener("keydown", closeOnEscape);
172
+ };
173
+ }, [open]);
174
+
175
+ const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
176
+ if (event.key === "Tab") {
177
+ setOpen(false);
178
+ return;
179
+ }
180
+ const offsets: Record<string, number> = {
181
+ ArrowLeft: -1,
182
+ ArrowRight: 1,
183
+ ArrowUp: -4,
184
+ ArrowDown: 4,
185
+ };
186
+ const offset = offsets[event.key];
187
+ if (offset === undefined && event.key !== "Home" && event.key !== "End") return;
188
+ event.preventDefault();
189
+ const items = Array.from(
190
+ event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
191
+ );
192
+ if (items.length === 0) return;
193
+ const current = Math.max(0, items.indexOf(document.activeElement as HTMLButtonElement));
194
+ const next =
195
+ event.key === "Home"
196
+ ? 0
197
+ : event.key === "End"
198
+ ? items.length - 1
199
+ : (current + (offset ?? 0) + items.length) % items.length;
200
+ items[next]?.focus();
201
+ };
202
+
203
+ return (
204
+ <div className="relative mb-2">
205
+ <button
206
+ ref={triggerRef}
207
+ type="button"
208
+ data-ease-type-dropdown=""
209
+ aria-haspopup="menu"
210
+ aria-expanded={open}
211
+ aria-controls={open ? menuId : undefined}
212
+ onClick={() => setOpen((prev) => !prev)}
213
+ className="flex w-full items-center gap-2 rounded-md border border-white/10 bg-black/20 px-2 py-1.5 text-left transition-colors hover:border-white/20"
214
+ >
215
+ <MiniCurveSvg ease={ease} active size={16} />
216
+ <span className="text-[11px] text-neutral-200">{label}</span>
217
+ <svg
218
+ width="8"
219
+ height="8"
220
+ viewBox="0 0 10 10"
221
+ fill="currentColor"
222
+ className={`ml-auto text-neutral-500 transition-transform ${open ? "rotate-180" : ""}`}
223
+ >
224
+ <path d="M2 3l3 4 3-4z" />
225
+ </svg>
226
+ </button>
227
+ {open && (
228
+ <div
229
+ ref={menuRef}
230
+ id={menuId}
231
+ role="menu"
232
+ aria-label={`${MODE_LABELS[kind]} ease presets`}
233
+ onKeyDown={handleMenuKeyDown}
234
+ className="absolute inset-x-0 top-full z-20 mt-1 rounded-md border border-white/10 bg-neutral-900 p-2 shadow-xl"
235
+ >
236
+ <EasePresetGrid
237
+ kind={kind}
238
+ currentEase={ease}
239
+ onSelect={(next) => {
240
+ onSelect(next);
241
+ setOpen(false);
242
+ triggerRef.current?.focus();
243
+ }}
244
+ />
245
+ </div>
246
+ )}
247
+ </div>
248
+ );
249
+ }
250
+
251
+ function resolveEditableCurve(ease: string, springBounce: number | null): Pts | null {
252
+ if (springBounce !== null) return DEFAULT_CURVE;
253
+ if (ease === "hold") return null;
254
+ if (ease.startsWith("custom(") || ease in EASE_CURVES) return resolveEaseCurveTuple(ease);
255
+ return null;
256
+ }
257
+
258
+ function resolveEditorLabel(ease: string, springBounce: number | null, isWiggle: boolean): string {
259
+ const presetLabel = easePresetLabel(ease);
260
+ if (presetLabel !== null) return presetLabel;
261
+ if (springBounce !== null) return "Custom spring";
262
+ if (isWiggle) return "Custom wiggle";
263
+ if (ease.startsWith("custom(")) return "Custom bezier";
264
+ return EASE_LABELS[ease] ?? ease;
265
+ }
104
266
 
105
267
  const xToSvg = (px: number) => PADH + S * px;
106
268
  const yToSvg = (py: number) => HR + S * (1 - py);
107
269
  const clampView = (py: number) => Math.max(VMIN, Math.min(VMAX, py));
270
+ const clampDragY = (py: number) => Math.max(DRAG_VMIN, Math.min(DRAG_VMAX, py));
271
+
272
+ function nudgeCurve(tuple: Pts, handle: "p1" | "p2", key: string, step: number): Pts | null {
273
+ const deltaX = key === "ArrowLeft" ? -step : key === "ArrowRight" ? step : 0;
274
+ const deltaY = key === "ArrowDown" ? -step : key === "ArrowUp" ? step : 0;
275
+ if (deltaX === 0 && deltaY === 0) return null;
276
+ const next: Pts = [...tuple];
277
+ const xIndex = handle === "p1" ? 0 : 2;
278
+ const yIndex = handle === "p1" ? 1 : 3;
279
+ next[xIndex] = round2(Math.max(0, Math.min(1, next[xIndex] + deltaX)));
280
+ next[yIndex] = round2(clampDragY(next[yIndex] + deltaY));
281
+ return next;
282
+ }
108
283
 
109
- function cubicAt(t: number, c0: number, c1: number, c2: number, c3: number): number {
110
- const mt = 1 - t;
111
- return mt * mt * mt * c0 + 3 * mt * mt * t * c1 + 3 * mt * t * t * c2 + t * t * t * c3;
284
+ function curvePathFor(
285
+ ease: string,
286
+ springBounce: number | null,
287
+ wiggleConfig: WiggleEaseConfig | null,
288
+ tuple: Pts,
289
+ ): string {
290
+ if (ease === "hold") return holdCurvePath(xToSvg(0), yToSvg(0), xToSvg(1), yToSvg(1));
291
+ if (wiggleConfig !== null) {
292
+ return sampledPath(64, xToSvg, yToSvg, (progress) =>
293
+ evaluateWiggleEase(progress, wiggleConfig.wiggles, wiggleConfig.type, wiggleConfig.amplitude),
294
+ );
295
+ }
296
+ if (springBounce !== null) {
297
+ return sampledPath(64, xToSvg, yToSvg, (progress) =>
298
+ evaluateSpringEase(progress, springBounce),
299
+ );
300
+ }
301
+ const [x1, y1, x2, y2] = tuple;
302
+ return `M${xToSvg(0)},${yToSvg(0)} C${xToSvg(x1)},${yToSvg(y1)} ${xToSvg(x2)},${yToSvg(y2)} ${xToSvg(1)},${yToSvg(1)}`;
303
+ }
304
+
305
+ function EaseParameterField({
306
+ springBounce,
307
+ wiggleConfig,
308
+ tuple,
309
+ onCommit,
310
+ }: {
311
+ springBounce: number | null;
312
+ wiggleConfig: WiggleEaseConfig | null;
313
+ tuple: Pts;
314
+ onCommit: (ease: string) => void;
315
+ }) {
316
+ if (springBounce !== null) {
317
+ return <SpringBounceField springBounce={springBounce} onCommit={onCommit} />;
318
+ }
319
+ if (wiggleConfig !== null) return <WiggleField config={wiggleConfig} onCommit={onCommit} />;
320
+ return <EaseBezierField tuple={tuple} onCommit={onCommit} />;
112
321
  }
113
322
 
114
323
  export function EaseCurveSection({
115
324
  ease,
116
- duration,
117
325
  onCustomEaseCommit,
118
326
  }: {
119
327
  ease: string;
120
- duration?: number;
121
328
  onCustomEaseCommit: (ease: string) => void;
122
329
  }) {
123
- const isCustom = ease.startsWith("custom(");
124
- const curveFromPreset = EASE_CURVES[ease];
125
- const customPoints = isCustom ? parseCustomEaseFromString(ease) : null;
126
- const curve: Pts | null =
127
- isCustom && customPoints
128
- ? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2]
129
- : (curveFromPreset ?? null);
330
+ const springBounce = parseSpringBounce(ease);
331
+ const isSpring = springBounce !== null;
332
+ const wiggleConfig = parseWiggleEase(ease);
333
+ const isWiggle = wiggleConfig !== null;
334
+ const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve";
335
+ const curve = resolveEditableCurve(ease, springBounce);
130
336
 
131
337
  const [draft, setDraft] = useState<Pts | null>(null);
132
- const [progress, setProgress] = useState<number | null>(null);
133
338
  const [hover, setHover] = useState<"p1" | "p2" | null>(null);
134
339
  const draggingRef = useRef<"p1" | "p2" | null>(null);
135
340
  const svgRef = useRef<SVGSVGElement | null>(null);
136
- const rafRef = useRef<number>(0);
137
341
 
138
- const play = useCallback(() => {
139
- const start = performance.now();
140
- const dur = 1100;
141
- const tick = (now: number) => {
142
- const t = Math.min((now - start) / dur, 1);
143
- setProgress(t);
144
- if (t < 1) rafRef.current = requestAnimationFrame(tick);
145
- else setTimeout(() => setProgress(null), 450);
146
- };
147
- cancelAnimationFrame(rafRef.current);
148
- rafRef.current = requestAnimationFrame(tick);
149
- }, []);
342
+ // Keep the local draft displayed until the committed `ease` prop round-trips
343
+ // back (write → reparse → re-render), then drop it. Clearing on pointer-up
344
+ // instead would fall back to the STALE prop for a frame — the curve snaps to
345
+ // the old value and jumps to the new one (the commit flicker). By the time
346
+ // `ease` changes, `curve` already equals the draft, so the handoff is seamless.
347
+ useEffect(() => {
348
+ setDraft(null);
349
+ }, [ease]);
150
350
 
151
- const active = draft ?? curve;
152
- if (!active) return null;
153
- const [x1, y1, x2, y2] = active;
351
+ const activeTuple = draft ?? curve;
352
+ const displayTuple = activeTuple ?? DEFAULT_CURVE;
353
+ const [x1, y1, x2, y2] = displayTuple;
154
354
 
155
355
  // Anchors + control handles. Handle *display* is clamped to the view so an
156
356
  // extreme loaded overshoot rides the edge instead of disappearing.
@@ -158,18 +358,9 @@ export function EaseCurveSection({
158
358
  const a1 = { x: xToSvg(1), y: yToSvg(1) };
159
359
  const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) };
160
360
  const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) };
161
- // Curve drawn from the true control points (so its shape is exact).
162
- const cp1 = { x: xToSvg(x1), y: yToSvg(y1) };
163
- const cp2 = { x: xToSvg(x2), y: yToSvg(y2) };
164
- const curvePath = `M${a0.x},${a0.y} C${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${a1.x},${a1.y}`;
165
-
166
- let dot: { x: number; y: number } | null = null;
167
- if (progress !== null) {
168
- dot = {
169
- x: xToSvg(cubicAt(progress, 0, x1, x2, 1)),
170
- y: yToSvg(cubicAt(progress, 0, y1, y2, 1)),
171
- };
172
- }
361
+ const curvePath = curvePathFor(ease, springBounce, wiggleConfig, displayTuple);
362
+ const showGraph = activeTuple !== null || isWiggle || ease === "hold";
363
+ const showHandles = curve !== null && !isSpring && !isWiggle;
173
364
 
174
365
  const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
175
366
  e.preventDefault();
@@ -190,7 +381,7 @@ export function EaseCurveSection({
190
381
  const px = Math.max(0, Math.min(1, (sx - PADH) / S));
191
382
  // py uses the WIDER drag bound (not clampView), so dragging keeps overshoot
192
383
  // fidelity instead of pinning the committed value to the visible view edge.
193
- const py = Math.max(DRAG_VMIN, Math.min(DRAG_VMAX, 1 - (sy - HR) / S));
384
+ const py = clampDragY(1 - (sy - HR) / S);
194
385
  const prev = draft ?? [x1, y1, x2, y2];
195
386
  const next: Pts =
196
387
  draggingRef.current === "p1"
@@ -203,164 +394,180 @@ export function EaseCurveSection({
203
394
  if (!draggingRef.current || !draft) return;
204
395
  draggingRef.current = null;
205
396
  const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`;
397
+ // Clear after the synchronous parent commit settles. This also clears a
398
+ // same-string commit, where the `ease` dependency effect would not run.
206
399
  onCustomEaseCommit(`custom(${path})`);
207
- setDraft(null);
400
+ queueMicrotask(() => setDraft(null));
401
+ };
402
+
403
+ const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent<SVGCircleElement>) => {
404
+ const next = nudgeCurve(displayTuple, handle, event.key, event.shiftKey ? 0.1 : 0.01);
405
+ if (!next) return;
406
+ event.preventDefault();
407
+ event.stopPropagation();
408
+ setDraft(next);
409
+ onCustomEaseCommit(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`);
410
+ queueMicrotask(() => setDraft(null));
208
411
  };
209
412
 
210
413
  const top = yToSvg(1);
211
414
  const bottom = yToSvg(0);
212
415
  const left = xToSvg(0);
213
416
  const right = xToSvg(1);
214
- const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease);
215
- const bezierText = `${x1} · ${y1} · ${x2} · ${y2}`;
417
+ const label = resolveEditorLabel(ease, springBounce, isWiggle);
216
418
 
217
419
  return (
218
420
  <div className="rounded-lg bg-neutral-900/50 p-2">
219
- <EasePresetGrid currentEase={ease} onSelect={(name) => onCustomEaseCommit(name)} />
220
- <div className="mb-1.5 flex items-center justify-between">
221
- <span className="text-[10px] font-medium text-neutral-500">Speed curve</span>
222
- <button
223
- type="button"
224
- onClick={play}
225
- className="rounded px-1.5 py-0.5 text-[10px] font-medium text-panel-accent transition-colors hover:bg-panel-accent/10"
226
- >
227
- {progress !== null ? "Playing…" : "Preview"}
228
- </button>
229
- </div>
230
- <div
231
- className="mx-auto overflow-hidden rounded-md border border-white/5 bg-black/20"
232
- style={{ aspectRatio: `${SVGW} / ${SVGH}`, width: "100%", maxWidth: 230 }}
233
- >
234
- <svg
235
- ref={svgRef}
236
- width="100%"
237
- height="100%"
238
- viewBox={`0 0 ${SVGW} ${SVGH}`}
239
- preserveAspectRatio="none"
240
- className="touch-none select-none"
241
- onPointerMove={handlePointerMove}
242
- onPointerUp={handlePointerUp}
243
- onPointerCancel={handlePointerUp}
244
- >
245
- {/* Grid — quarter lines inside the unit square */}
246
- {[0.25, 0.5, 0.75].map((q) => (
247
- <line
248
- key={`v${q}`}
249
- x1={xToSvg(q)}
250
- y1={top}
251
- x2={xToSvg(q)}
252
- y2={bottom}
253
- stroke="white"
254
- strokeOpacity="0.05"
255
- strokeWidth="1"
256
- />
257
- ))}
258
- {[0.25, 0.5, 0.75].map((q) => (
259
- <line
260
- key={`h${q}`}
261
- x1={left}
262
- y1={yToSvg(q)}
263
- x2={right}
264
- y2={yToSvg(q)}
265
- stroke="white"
266
- strokeOpacity="0.05"
267
- strokeWidth="1"
268
- />
269
- ))}
270
- {/* Unit-square frame (progress 0 → 1) */}
271
- <rect
272
- x={left}
273
- y={top}
274
- width={S}
275
- height={bottom - top}
276
- fill="none"
277
- stroke="white"
278
- strokeOpacity="0.1"
279
- strokeWidth="1"
280
- />
281
- {/* Linear reference diagonal */}
282
- <line
283
- x1={a0.x}
284
- y1={a0.y}
285
- x2={a1.x}
286
- y2={a1.y}
287
- stroke="white"
288
- strokeOpacity="0.08"
289
- strokeWidth="1"
290
- strokeDasharray="3 4"
291
- />
292
- {/* Tangent handle lines */}
293
- <line
294
- x1={a0.x}
295
- y1={a0.y}
296
- x2={p1.x}
297
- y2={p1.y}
298
- stroke={ACCENT}
299
- strokeOpacity="0.5"
300
- strokeWidth="1.5"
301
- />
302
- <line
303
- x1={a1.x}
304
- y1={a1.y}
305
- x2={p2.x}
306
- y2={p2.y}
307
- stroke={ACCENT}
308
- strokeOpacity="0.5"
309
- strokeWidth="1.5"
310
- />
311
- {/* The curve */}
312
- <path d={curvePath} fill="none" stroke={ACCENT} strokeWidth="2.5" strokeLinecap="round" />
313
- {/* Anchors at (0,0) and (1,1) */}
314
- <circle cx={a0.x} cy={a0.y} r="3" fill={ACCENT} />
315
- <circle cx={a1.x} cy={a1.y} r="3" fill={ACCENT} />
316
- {/* Animated preview dot */}
317
- {dot && (
318
- <>
319
- <circle cx={dot.x} cy={dot.y} r="9" fill={ACCENT} fillOpacity="0.18" />
320
- <circle cx={dot.x} cy={dot.y} r="4.5" fill={ACCENT} />
321
- </>
322
- )}
323
- {/* Draggable control handles (large transparent hit area + visible dot) */}
324
- {[["p1", p1] as const, ["p2", p2] as const].map(([key, pt]) => (
325
- <g key={key}>
326
- <circle
327
- cx={pt.x}
328
- cy={pt.y}
329
- r="14"
330
- fill="transparent"
331
- className="cursor-grab active:cursor-grabbing"
332
- onPointerDown={(e) => handlePointerDown(key, e)}
333
- onPointerEnter={() => setHover(key)}
334
- onPointerLeave={() => setHover((h) => (h === key ? null : h))}
421
+ <EaseTypeDropdown kind={mode} ease={ease} label={label} onSelect={onCustomEaseCommit} />
422
+ <EaseModeToggle mode={mode} onCommit={onCustomEaseCommit} />
423
+ <span className="sr-only" aria-live="polite">
424
+ {MODE_LABELS[mode]} ease editor selected
425
+ </span>
426
+ {showGraph ? (
427
+ <>
428
+ <div
429
+ className="mx-auto overflow-hidden rounded-md border border-white/5 bg-black/20"
430
+ style={{ aspectRatio: `${SVGW} / ${SVGH}`, width: "100%", maxWidth: 230 }}
431
+ >
432
+ <svg
433
+ ref={svgRef}
434
+ width="100%"
435
+ height="100%"
436
+ viewBox={`0 0 ${SVGW} ${SVGH}`}
437
+ preserveAspectRatio="none"
438
+ className="touch-none select-none"
439
+ onPointerMove={handlePointerMove}
440
+ onPointerUp={handlePointerUp}
441
+ onPointerCancel={handlePointerUp}
442
+ >
443
+ {/* Grid — quarter lines inside the unit square */}
444
+ {[0.25, 0.5, 0.75].map((q) => (
445
+ <line
446
+ key={`v${q}`}
447
+ x1={xToSvg(q)}
448
+ y1={top}
449
+ x2={xToSvg(q)}
450
+ y2={bottom}
451
+ stroke="white"
452
+ strokeOpacity="0.05"
453
+ strokeWidth="1"
454
+ />
455
+ ))}
456
+ {[0.25, 0.5, 0.75].map((q) => (
457
+ <line
458
+ key={`h${q}`}
459
+ x1={left}
460
+ y1={yToSvg(q)}
461
+ x2={right}
462
+ y2={yToSvg(q)}
463
+ stroke="white"
464
+ strokeOpacity="0.05"
465
+ strokeWidth="1"
466
+ />
467
+ ))}
468
+ {/* Unit-square frame (progress 0 → 1) */}
469
+ <rect
470
+ x={left}
471
+ y={top}
472
+ width={S}
473
+ height={bottom - top}
474
+ fill="none"
475
+ stroke="white"
476
+ strokeOpacity="0.1"
477
+ strokeWidth="1"
478
+ />
479
+ {/* Linear reference diagonal */}
480
+ <line
481
+ x1={a0.x}
482
+ y1={a0.y}
483
+ x2={a1.x}
484
+ y2={a1.y}
485
+ stroke="white"
486
+ strokeOpacity="0.08"
487
+ strokeWidth="1"
488
+ strokeDasharray="3 4"
335
489
  />
336
- <circle
337
- cx={pt.x}
338
- cy={pt.y}
339
- r={hover === key || draggingRef.current === key ? 7 : 5.5}
340
- fill="#0a0a1a"
490
+ {/* Tangent handle lines */}
491
+ {showHandles && (
492
+ <>
493
+ <line
494
+ x1={a0.x}
495
+ y1={a0.y}
496
+ x2={p1.x}
497
+ y2={p1.y}
498
+ stroke={ACCENT}
499
+ strokeOpacity="0.5"
500
+ strokeWidth="1.5"
501
+ />
502
+ <line
503
+ x1={a1.x}
504
+ y1={a1.y}
505
+ x2={p2.x}
506
+ y2={p2.y}
507
+ stroke={ACCENT}
508
+ strokeOpacity="0.5"
509
+ strokeWidth="1.5"
510
+ />
511
+ </>
512
+ )}
513
+ {/* The curve */}
514
+ <path
515
+ d={curvePath}
516
+ fill="none"
341
517
  stroke={ACCENT}
342
518
  strokeWidth="2.5"
343
- className="pointer-events-none transition-[r]"
519
+ strokeLinecap="round"
344
520
  />
345
- </g>
346
- ))}
347
- </svg>
348
- </div>
349
- {/* Axis + value readout */}
350
- <div className="mt-1.5 flex items-center justify-between px-0.5 text-[9px] text-neutral-600">
351
- <span>{duration != null && duration > 0 ? "0s" : "start"}</span>
352
- <span className="tracking-wide text-neutral-500">time →</span>
353
- <span>{duration != null && duration > 0 ? `${duration}s` : "end"}</span>
354
- </div>
355
- <div className="mt-1 flex items-center justify-between px-0.5">
356
- <span className="text-[10px] text-neutral-400">{label}</span>
357
- <span
358
- className="font-mono text-[9px] tracking-tight text-neutral-600"
359
- title="cubic-bezier control points"
360
- >
361
- {bezierText}
362
- </span>
363
- </div>
521
+ {/* Anchors at (0,0) and (1,1) */}
522
+ <circle cx={a0.x} cy={a0.y} r="3" fill={ACCENT} />
523
+ <circle cx={a1.x} cy={a1.y} r="3" fill={ACCENT} />
524
+ {/* Draggable control handles (large transparent hit area + visible dot) */}
525
+ {showHandles &&
526
+ [["p1", p1] as const, ["p2", p2] as const].map(([key, pt]) => (
527
+ <g key={key}>
528
+ <circle
529
+ cx={pt.x}
530
+ cy={pt.y}
531
+ r="14"
532
+ fill="transparent"
533
+ role="slider"
534
+ tabIndex={0}
535
+ aria-label={`${key === "p1" ? "First" : "Second"} bezier control point`}
536
+ aria-valuemin={0}
537
+ aria-valuemax={1}
538
+ aria-valuenow={key === "p1" ? x1 : x2}
539
+ aria-valuetext={`x ${key === "p1" ? x1 : x2}, y ${key === "p1" ? y1 : y2}`}
540
+ className="cursor-grab stroke-transparent outline-none active:cursor-grabbing focus-visible:stroke-white focus-visible:stroke-[2px]"
541
+ onPointerDown={(e) => handlePointerDown(key, e)}
542
+ onKeyDown={(event) => handleKeyDown(key, event)}
543
+ onPointerEnter={() => setHover(key)}
544
+ onPointerLeave={() => setHover((h) => (h === key ? null : h))}
545
+ />
546
+ <circle
547
+ cx={pt.x}
548
+ cy={pt.y}
549
+ r={hover === key || draggingRef.current === key ? 7 : 5.5}
550
+ fill="#0a0a1a"
551
+ stroke={ACCENT}
552
+ strokeWidth="2.5"
553
+ className="pointer-events-none transition-[r]"
554
+ />
555
+ </g>
556
+ ))}
557
+ </svg>
558
+ </div>
559
+ <EaseParameterField
560
+ springBounce={springBounce}
561
+ wiggleConfig={wiggleConfig}
562
+ tuple={displayTuple}
563
+ onCommit={onCustomEaseCommit}
564
+ />
565
+ </>
566
+ ) : (
567
+ <p className="px-0.5 py-1.5 text-[10px] leading-relaxed text-neutral-500">
568
+ {label} preset: switch to Curve, Spring, or Wiggle above to shape it by hand.
569
+ </p>
570
+ )}
364
571
  </div>
365
572
  );
366
573
  }