@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.
- package/dist/assets/{hyperframes-player-C1X90psg.js → hyperframes-player-DGlzhf_s.js} +1 -1
- package/dist/assets/index-B37rWXo4.css +1 -0
- package/dist/assets/{index-DzxDHPR1.js → index-CbVTOJ-E.js} +1 -1
- package/dist/assets/{index-nY4lLBqM.js → index-CcA5WVJv.js} +1 -1
- package/dist/assets/index-DXcLI2wu.js +428 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +2 -2
- package/dist/index.js +7762 -4261
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -4
- package/src/components/StudioLeftSidebar.tsx +2 -3
- package/src/components/StudioRightPanel.tsx +10 -4
- package/src/components/editor/AnimationCard.test.tsx +100 -0
- package/src/components/editor/AnimationCard.tsx +5 -2
- package/src/components/editor/EaseCurveSection.test.tsx +348 -0
- package/src/components/editor/EaseCurveSection.tsx +444 -237
- package/src/components/editor/EaseParamFields.test.tsx +188 -0
- package/src/components/editor/EaseParamFields.tsx +246 -0
- package/src/components/editor/PropertyPanel.test.tsx +9 -7
- package/src/components/editor/PropertyPanelFlat.tsx +75 -88
- package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
- package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
- package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
- package/src/components/editor/easeCurveSvg.tsx +65 -0
- package/src/components/editor/easePresetLibrary.test.ts +114 -0
- package/src/components/editor/easePresetLibrary.ts +49 -0
- package/src/components/editor/gsapAnimationConstants.ts +15 -13
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
- package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
- package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
- package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
- package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
- package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
- package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
- package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
- package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +239 -41
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +260 -300
- package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
- package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
- package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
- package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
- package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
- package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
- package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
- package/src/components/editor/propertyPanelTypes.ts +13 -0
- package/src/components/editor/useColorGradingController.test.ts +331 -10
- package/src/components/editor/useColorGradingController.ts +57 -90
- package/src/components/editor/useColorGradingPreviews.ts +350 -0
- package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
- package/src/components/editor/useColorGradingScopes.ts +62 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/contexts/PanelLayoutContext.tsx +3 -6
- package/src/hooks/useBlockCatalog.ts +31 -13
- package/src/hooks/useBlockHandlers.ts +22 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
- package/src/hooks/useGsapScriptCommits.ts +14 -1
- package/src/hooks/usePanelLayout.test.ts +68 -1
- package/src/hooks/usePanelLayout.ts +72 -34
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/components/PlayerControls.tsx +68 -229
- package/src/player/components/ShortcutsPanel.tsx +2 -4
- package/src/player/components/SpeedMenu.tsx +1 -2
- package/src/telemetry/events.test.ts +9 -0
- package/src/telemetry/events.ts +5 -0
- package/src/utils/blockInstaller.test.ts +67 -0
- package/src/utils/blockInstaller.ts +162 -109
- package/src/utils/studioUiPreferences.test.ts +5 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/assets/index-CSCh2Vrp.js +0 -428
- package/dist/assets/index-Ceyz8Qt2.css +0 -1
- package/src/player/components/PlayerControls.test.ts +0 -20
- package/src/player/components/useSeekBarDrag.ts +0 -169
|
@@ -1,75 +1,55 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
54
|
-
{
|
|
55
|
-
const
|
|
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={
|
|
34
|
+
key={preset.id}
|
|
61
35
|
type="button"
|
|
62
|
-
|
|
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={
|
|
44
|
+
title={preset.label}
|
|
67
45
|
>
|
|
68
|
-
<MiniCurveSvg
|
|
46
|
+
<MiniCurveSvg ease={preset.ease} active={isActive} />
|
|
69
47
|
<span
|
|
70
|
-
className={`text-[8px] leading-none ${
|
|
48
|
+
className={`text-center text-[8px] leading-none ${
|
|
49
|
+
isActive ? "text-panel-accent" : "text-neutral-500"
|
|
50
|
+
}`}
|
|
71
51
|
>
|
|
72
|
-
{
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
const [x1, y1, x2, y2] =
|
|
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
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
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 =
|
|
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 =
|
|
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
|
-
<
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
519
|
+
strokeLinecap="round"
|
|
344
520
|
/>
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
}
|