@hyperframes/studio 0.7.5 → 0.7.6

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 (61) hide show
  1. package/dist/assets/{index-BwFzbjZQ.js → index-B_NnmU6q.js} +1 -1
  2. package/dist/assets/{index-C5NAfiPa.js → index-DsBhGFPe.js} +1 -1
  3. package/dist/assets/{index-DzWIinxk.css → index-wJZf6FK3.css} +1 -1
  4. package/dist/assets/index-ysftPins.js +374 -0
  5. package/dist/chunk-KZXYQYIU.js +876 -0
  6. package/dist/chunk-KZXYQYIU.js.map +1 -0
  7. package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
  8. package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +2298 -2483
  12. package/dist/index.js.map +1 -1
  13. package/package.json +5 -5
  14. package/src/captions/hooks/useCaptionSync.ts +6 -1
  15. package/src/components/StudioPreviewArea.tsx +10 -3
  16. package/src/components/StudioRightPanel.tsx +2 -0
  17. package/src/components/editor/AnimationCard.tsx +72 -251
  18. package/src/components/editor/AnimationCardParts.tsx +220 -0
  19. package/src/components/editor/BlockParamsPanel.tsx +4 -8
  20. package/src/components/editor/DomEditOverlay.tsx +132 -48
  21. package/src/components/editor/EaseCurveSection.tsx +6 -6
  22. package/src/components/editor/GsapAnimationSection.tsx +2 -0
  23. package/src/components/editor/KeyframeEaseList.tsx +63 -0
  24. package/src/components/editor/OffCanvasIndicators.tsx +111 -0
  25. package/src/components/editor/PropertyPanel.tsx +52 -54
  26. package/src/components/editor/gsapAnimationCallbacks.ts +1 -0
  27. package/src/components/editor/gsapAnimationConstants.ts +8 -0
  28. package/src/components/editor/manualOffsetDrag.ts +8 -0
  29. package/src/components/editor/marqueeCommit.ts +168 -0
  30. package/src/components/editor/propertyPanelHelpers.ts +1 -0
  31. package/src/components/editor/snapTargetCollection.ts +0 -5
  32. package/src/components/panels/SlideshowPanel.tsx +0 -1
  33. package/src/contexts/DomEditContext.tsx +8 -0
  34. package/src/hooks/gsapDragPositionCommit.ts +1 -2
  35. package/src/hooks/gsapRuntimeBridge.ts +6 -2
  36. package/src/hooks/gsapRuntimeReaders.ts +1 -6
  37. package/src/hooks/useDomEditCommits.ts +0 -4
  38. package/src/hooks/useDomEditPreviewSync.ts +5 -0
  39. package/src/hooks/useDomEditSession.ts +23 -0
  40. package/src/hooks/useDomEditTextCommits.ts +3 -12
  41. package/src/hooks/useDomSelection.ts +46 -0
  42. package/src/hooks/useGestureCommit.ts +26 -3
  43. package/src/hooks/useGestureRecording.ts +2 -16
  44. package/src/hooks/useGsapAnimationOps.ts +2 -2
  45. package/src/hooks/useGsapTweenCache.ts +32 -4
  46. package/src/player/components/Player.tsx +0 -5
  47. package/src/player/components/timelineIcons.tsx +2 -1
  48. package/src/player/hooks/useTimelinePlayer.ts +4 -14
  49. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
  50. package/src/player/lib/timelineIframeHelpers.ts +2 -6
  51. package/src/telemetry/client.ts +2 -0
  52. package/src/utils/editDebugLog.ts +3 -10
  53. package/src/utils/gestureSmoother.test.ts +48 -0
  54. package/src/utils/gestureSmoother.ts +46 -0
  55. package/src/utils/marqueeGeometry.test.ts +123 -0
  56. package/src/utils/marqueeGeometry.ts +172 -0
  57. package/src/utils/optimisticUpdate.ts +1 -2
  58. package/src/utils/sourcePatcher.ts +0 -10
  59. package/src/utils/velocityEaseFitter.test.ts +58 -0
  60. package/src/utils/velocityEaseFitter.ts +121 -0
  61. package/dist/assets/index-D_JGXmfx.js +0 -374
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,9 +45,9 @@
45
45
  "dompurify": "^3.2.4",
46
46
  "marked": "^14.1.4",
47
47
  "mediabunny": "^1.45.3",
48
- "@hyperframes/player": "0.7.5",
49
- "@hyperframes/sdk": "0.7.5",
50
- "@hyperframes/core": "0.7.5"
48
+ "@hyperframes/core": "0.7.6",
49
+ "@hyperframes/sdk": "0.7.6",
50
+ "@hyperframes/player": "0.7.6"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/react": "19",
@@ -62,7 +62,7 @@
62
62
  "vite": "^6.4.2",
63
63
  "vitest": "^3.2.4",
64
64
  "zustand": "^5.0.0",
65
- "@hyperframes/producer": "0.7.5"
65
+ "@hyperframes/producer": "0.7.6"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "react": "19",
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import { useCaptionStore } from "../store";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import { trackEvent } from "../../telemetry/client";
4
5
  import type { CaptionStyle } from "../types";
5
6
 
6
7
  interface CaptionOverrideEntry {
@@ -78,7 +79,11 @@ export function useCaptionSync(projectId: string | null) {
78
79
  method: "PUT",
79
80
  headers: { "Content-Type": "text/plain" },
80
81
  body: JSON.stringify(overrides, null, 2),
81
- }).catch((err) => console.warn("[captions] auto-save failed:", err));
82
+ }).catch((error: unknown) => {
83
+ // Caption auto-save is a data-loss path; surface failures via telemetry
84
+ // so a silently-dropped edit isn't invisible (no console in studio).
85
+ trackEvent("studio_caption_autosave_failed", { error: String(error) });
86
+ });
82
87
  }, []);
83
88
 
84
89
  // Auto-save on model changes with 800ms debounce
@@ -17,7 +17,7 @@ import {
17
17
  STUDIO_PREVIEW_SELECTION_ENABLED,
18
18
  } from "./editor/manualEditingAvailability";
19
19
  import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
20
- import { useDomEditContext } from "../contexts/DomEditContext";
20
+ import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
21
21
  import { TimelineEditProvider } from "../contexts/TimelineEditContext";
22
22
  import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
23
23
  import { readStudioUiPreferences } from "../utils/studioUiPreferences";
@@ -117,6 +117,9 @@ export function StudioPreviewArea({
117
117
  domEditHoverSelection,
118
118
  domEditSelection,
119
119
  domEditGroupSelections,
120
+ selectedGsapAnimations,
121
+ } = useDomEditSelectionContext();
122
+ const {
120
123
  handleTimelineElementSelect,
121
124
  handlePreviewCanvasMouseDown,
122
125
  handlePreviewCanvasPointerMove,
@@ -128,15 +131,16 @@ export function StudioPreviewArea({
128
131
  handleDomGroupPathOffsetCommit,
129
132
  handleDomBoxSizeCommit,
130
133
  handleDomRotationCommit,
131
- selectedGsapAnimations,
132
134
  handleGsapRemoveKeyframe,
133
135
  handleGsapUpdateMeta,
134
136
  handleGsapAddKeyframe,
135
137
  handleGsapConvertToKeyframes,
136
138
  handleGsapDeleteAllForElement,
137
139
  buildDomSelectionForTimelineElement,
138
- } = useDomEditContext();
140
+ applyMarqueeSelection,
141
+ } = useDomEditActionsContext();
139
142
 
143
+ // fallow-ignore-next-line complexity
140
144
  const [snapPrefs, setSnapPrefs] = useState(() => {
141
145
  const p = readStudioUiPreferences();
142
146
  return {
@@ -160,6 +164,7 @@ export function StudioPreviewArea({
160
164
  const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId;
161
165
  handleGsapDeleteAllForElement(`#${rawId}`);
162
166
  },
167
+ // fallow-ignore-next-line complexity
163
168
  onDeleteKeyframe: (_elId: string, pct: number) => {
164
169
  const cacheKey = domEditSelection?.id ?? "";
165
170
  const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
@@ -215,6 +220,7 @@ export function StudioPreviewArea({
215
220
  }
216
221
  }
217
222
  },
223
+ // fallow-ignore-next-line complexity
218
224
  onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
219
225
  const currentTime = usePlayerStore.getState().currentTime;
220
226
  const pct =
@@ -339,6 +345,7 @@ export function StudioPreviewArea({
339
345
  gridSpacing={snapPrefs.gridSpacing}
340
346
  recordingState={recordingState}
341
347
  onToggleRecording={onToggleRecording}
348
+ onMarqueeSelect={applyMarqueeSelection}
342
349
  />
343
350
  <SnapToolbar onSnapChange={setSnapPrefs} />
344
351
  {STUDIO_KEYFRAMES_ENABLED && (
@@ -121,6 +121,7 @@ export function StudioRightPanel({
121
121
  handleSetArcPath,
122
122
  handleUpdateArcSegment,
123
123
  handleUnroll,
124
+ handleUpdateKeyframeEase,
124
125
  handleGsapAddKeyframe,
125
126
  handleGsapRemoveKeyframe,
126
127
  handleGsapConvertToKeyframes,
@@ -274,6 +275,7 @@ export function StudioRightPanel({
274
275
  onSetArcPath={handleSetArcPath}
275
276
  onUpdateArcSegment={handleUpdateArcSegment}
276
277
  onUnroll={handleUnroll}
278
+ onUpdateKeyframeEase={handleUpdateKeyframeEase}
277
279
  recordingState={recordingState}
278
280
  recordingDuration={recordingDuration}
279
281
  onToggleRecording={onToggleRecording}
@@ -4,237 +4,19 @@ import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constan
4
4
  import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
5
5
  import { MetricField, SelectField } from "./propertyPanelPrimitives";
6
6
  import { controlPointsForGsapEase } from "./studioMotion";
7
- import {
8
- EASE_LABELS,
9
- METHOD_LABELS,
10
- METHOD_TOOLTIPS,
11
- PERCENT_PROPS,
12
- PROP_CONSTRAINTS,
13
- PROP_LABELS,
14
- PROP_TOOLTIPS,
15
- PROP_UNITS,
16
- clampPropertyValue,
17
- } from "./gsapAnimationConstants";
7
+ import { EASE_LABELS, METHOD_LABELS, METHOD_TOOLTIPS, PROP_LABELS } from "./gsapAnimationConstants";
18
8
  import { buildTweenSummary } from "./gsapAnimationHelpers";
19
9
  import { EaseCurveSection } from "./EaseCurveSection";
20
10
  import { ArcPathControls } from "./ArcPathControls";
21
11
  import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
22
12
  import { ComputedTweenNotice } from "./ComputedTweenNotice";
23
- import { P } from "./panelTokens";
24
- const BOOLEAN_PROPS = new Set(["visibility"]);
25
- const STRING_PROPS = new Set(["filter", "clipPath"]);
26
-
27
- const FILTER_PRESETS = [
28
- { label: "Blur", value: "blur(4px)" },
29
- { label: "Bright", value: "brightness(1.5)" },
30
- { label: "Gray", value: "grayscale(1)" },
31
- { label: "None", value: "none" },
32
- ];
33
-
34
- const CLIP_PATH_PRESETS = [
35
- { label: "Circle", value: "circle(50% at 50% 50%)" },
36
- { label: "Inset", value: "inset(10%)" },
37
- { label: "None", value: "none" },
38
- ];
39
-
40
- function isPercentProp(prop: string): boolean {
41
- return PERCENT_PROPS.has(prop);
42
- }
43
-
44
- function displayValue(prop: string, val: number | string): string {
45
- if (isPercentProp(prop)) return String(Math.round(Math.max(0, Math.min(1, Number(val))) * 100));
46
- return String(val);
47
- }
48
-
49
- function adjustedValue(prop: string, raw: string): string {
50
- if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100));
51
- const num = Number(raw);
52
- if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) {
53
- return String(clampPropertyValue(prop, num));
54
- }
55
- return raw;
56
- }
57
-
58
- function RemoveButton({ onClick, title }: { onClick: () => void; title: string }) {
59
- return (
60
- <button
61
- type="button"
62
- onClick={onClick}
63
- className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
64
- title={title}
65
- >
66
- <svg
67
- width="12"
68
- height="12"
69
- viewBox="0 0 12 12"
70
- fill="none"
71
- stroke="currentColor"
72
- strokeWidth="1.5"
73
- >
74
- <path d="M3 3l6 6M9 3l-6 6" />
75
- </svg>
76
- </button>
77
- );
78
- }
79
-
80
- function PropertyRow({
81
- prop,
82
- val,
83
- onCommit,
84
- onRemove,
85
- removeTitle,
86
- }: {
87
- prop: string;
88
- val: number | string;
89
- onCommit: (adjusted: string) => void;
90
- onRemove: () => void;
91
- removeTitle: string;
92
- }) {
93
- if (BOOLEAN_PROPS.has(prop)) {
94
- const isVisible = val === "visible" || val === 1;
95
- return (
96
- <div className="flex items-center gap-1">
97
- <div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
98
- <span className="flex-1 text-[11px] font-medium text-neutral-500">
99
- {PROP_LABELS[prop] ?? prop}
100
- </span>
101
- <button
102
- type="button"
103
- onClick={() => onCommit(isVisible ? "hidden" : "visible")}
104
- className={`flex-shrink-0 rounded-full transition-all duration-150 relative`}
105
- style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
106
- title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
107
- >
108
- <span
109
- className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
110
- style={{
111
- width: 12,
112
- height: 12,
113
- background: isVisible ? P.white : P.textMuted,
114
- transform: isVisible ? "translateX(14px)" : "translateX(2px)",
115
- }}
116
- />
117
- </button>
118
- </div>
119
- <RemoveButton onClick={onRemove} title={removeTitle} />
120
- </div>
121
- );
122
- }
123
-
124
- if (STRING_PROPS.has(prop)) {
125
- const presets =
126
- prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : [];
127
- return (
128
- <div className="flex flex-col gap-1">
129
- <div className="flex items-center gap-1">
130
- <div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
131
- <span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">
132
- {PROP_LABELS[prop] ?? prop}
133
- </span>
134
- <input
135
- type="text"
136
- defaultValue={String(val)}
137
- className="flex-1 bg-transparent text-[11px] text-neutral-200 outline-none"
138
- onBlur={(e) => onCommit(e.currentTarget.value)}
139
- onKeyDown={(e) => {
140
- if (e.key === "Enter") {
141
- e.currentTarget.blur();
142
- }
143
- }}
144
- />
145
- </div>
146
- <RemoveButton onClick={onRemove} title={removeTitle} />
147
- </div>
148
- {presets.length > 0 && (
149
- <div className="flex gap-1 pl-1">
150
- {presets.map((p) => (
151
- <button
152
- key={p.value}
153
- type="button"
154
- onClick={() => onCommit(p.value)}
155
- className="px-1.5 py-0.5 rounded text-[9px] font-medium text-neutral-500 bg-neutral-800/50 hover:bg-neutral-800 hover:text-neutral-300 transition-colors"
156
- >
157
- {p.label}
158
- </button>
159
- ))}
160
- </div>
161
- )}
162
- </div>
163
- );
164
- }
165
-
166
- return (
167
- <div className="flex items-center gap-1">
168
- <div className="min-w-0 flex-1">
169
- <MetricField
170
- label={PROP_LABELS[prop] ?? prop}
171
- value={displayValue(prop, val)}
172
- suffix={PROP_UNITS[prop]}
173
- tooltip={PROP_TOOLTIPS[prop]}
174
- scrub
175
- liveCommit
176
- onCommit={(raw) => onCommit(adjustedValue(prop, raw))}
177
- />
178
- </div>
179
- <RemoveButton onClick={onRemove} title={removeTitle} />
180
- </div>
181
- );
182
- }
183
-
184
- function AddPropertyTrigger({
185
- adding,
186
- available,
187
- addLabel,
188
- addTitle,
189
- onAdd,
190
- onOpen,
191
- onClose,
192
- buttonClassName,
193
- }: {
194
- adding: boolean;
195
- available: string[];
196
- addLabel: string;
197
- addTitle: string;
198
- onAdd: (prop: string) => void;
199
- onOpen: () => void;
200
- onClose: () => void;
201
- buttonClassName: string;
202
- }) {
203
- if (adding && available.length > 0) {
204
- return (
205
- <select
206
- autoFocus
207
- className="min-w-0 rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-[11px] text-neutral-100 outline-none"
208
- defaultValue=""
209
- onChange={(e) => {
210
- if (e.target.value) onAdd(e.target.value);
211
- onClose();
212
- }}
213
- onBlur={onClose}
214
- >
215
- <option value="" disabled>
216
- Choose property…
217
- </option>
218
- {available.map((p) => (
219
- <option key={p} value={p}>
220
- {PROP_LABELS[p] ?? p}
221
- </option>
222
- ))}
223
- </select>
224
- );
225
- }
226
- if (available.length === 0) return null;
227
- return (
228
- <button type="button" onClick={onOpen} className={buttonClassName} title={addTitle}>
229
- {addLabel}
230
- </button>
231
- );
232
- }
233
-
234
- function parseNumericOrString(raw: string): number | string {
235
- const num = Number(raw);
236
- return Number.isFinite(num) ? num : raw;
237
- }
13
+ import { KeyframeEaseList } from "./KeyframeEaseList";
14
+ import {
15
+ PropertyRow,
16
+ AddPropertyTrigger,
17
+ parseNumericOrString,
18
+ BOOLEAN_PROPS,
19
+ } from "./AnimationCardParts";
238
20
 
239
21
  interface AnimationCardProps extends GsapAnimationEditCallbacks {
240
22
  animation: GsapAnimation;
@@ -257,11 +39,13 @@ export const AnimationCard = memo(function AnimationCard({
257
39
  onLivePreviewEnd,
258
40
  onSetArcPath,
259
41
  onUpdateArcSegment,
42
+ onUpdateKeyframeEase,
260
43
  onUnroll,
261
44
  }: AnimationCardProps) {
262
45
  const [expanded, setExpanded] = useState(defaultExpanded);
263
46
  const [addingProp, setAddingProp] = useState(false);
264
47
  const [addingFromProp, setAddingFromProp] = useState(false);
48
+ const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
265
49
 
266
50
  const usedProps = useMemo(
267
51
  () => new Set(Object.keys(animation.properties)),
@@ -330,7 +114,8 @@ export const AnimationCard = memo(function AnimationCard({
330
114
  const [copied, setCopied] = useState(false);
331
115
 
332
116
  const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
333
- const easeName = animation.ease ?? animation.keyframes?.easeEach ?? "none";
117
+ const easeName =
118
+ (animation.keyframes ? animation.keyframes.easeEach : undefined) ?? animation.ease ?? "none";
334
119
  const easeLabel = easeName.startsWith("custom(")
335
120
  ? "Custom curve"
336
121
  : (EASE_LABELS[easeName] ?? easeName);
@@ -340,6 +125,28 @@ export const AnimationCard = memo(function AnimationCard({
340
125
  : animation.position;
341
126
 
342
127
  const summary = useMemo(() => buildTweenSummary(animation), [animation]);
128
+ const setKeys = Object.keys(animation.properties);
129
+ if (
130
+ animation.method === "set" &&
131
+ // `every` is vacuously true on an empty bag — require at least one key so a
132
+ // property-less set doesn't masquerade as a position row.
133
+ (setKeys.includes("x") || setKeys.includes("y")) &&
134
+ setKeys.every((k) => k === "x" || k === "y" || k === "immediateRender")
135
+ )
136
+ return (
137
+ <div className="border-b border-neutral-800 pb-2">
138
+ <div className="flex items-center gap-2 py-1.5">
139
+ <span className="rounded bg-neutral-800 px-1.5 py-0.5 text-[10px] font-medium text-neutral-400">
140
+ Position
141
+ </span>
142
+ <span className="text-[11px] text-neutral-500">
143
+ x: {Math.round(Number(animation.properties.x ?? 0))}, y:{" "}
144
+ {Math.round(Number(animation.properties.y ?? 0))}
145
+ </span>
146
+ <span className="ml-auto text-[9px] text-neutral-600">drag to move</span>
147
+ </div>
148
+ </div>
149
+ );
343
150
 
344
151
  return (
345
152
  <div className="border-b border-neutral-800 pb-3">
@@ -393,7 +200,7 @@ export const AnimationCard = memo(function AnimationCard({
393
200
  clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
394
201
  }}
395
202
  />
396
- Keyframed — edit values in the Layout panel above
203
+ Keyframed — click a segment below to edit its curve
397
204
  </p>
398
205
  )}
399
206
  </div>
@@ -435,29 +242,43 @@ export const AnimationCard = memo(function AnimationCard({
435
242
 
436
243
  {animation.method !== "set" && (
437
244
  <>
438
- <SelectField
439
- label="Speed"
440
- value={easeName.startsWith("custom(") ? "custom" : easeName}
441
- options={[...SUPPORTED_EASES, "custom"]}
442
- onChange={(next) => {
443
- if (next === "custom") {
444
- const points = controlPointsForGsapEase(
445
- easeName !== "none" ? easeName : "power2.out",
446
- );
447
- const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
448
- onUpdateMeta(animation.id, { ease: `custom(${path})` });
449
- } else {
450
- onUpdateMeta(animation.id, { ease: next });
451
- }
452
- }}
453
- />
454
- <EaseCurveSection
455
- ease={easeName}
456
- duration={animation.duration}
457
- onCustomEaseCommit={(customEase) =>
458
- onUpdateMeta(animation.id, { ease: customEase })
459
- }
460
- />
245
+ {animation.keyframes && onUpdateKeyframeEase ? (
246
+ <KeyframeEaseList
247
+ keyframes={animation.keyframes.keyframes}
248
+ globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
249
+ expandedPct={expandedKfPct}
250
+ onToggle={setExpandedKfPct}
251
+ onEaseCommit={(pct, ease) => onUpdateKeyframeEase(animation.id, pct, ease)}
252
+ />
253
+ ) : (
254
+ <>
255
+ <SelectField
256
+ label="Speed"
257
+ value={easeName.startsWith("custom(") ? "custom" : easeName}
258
+ options={[...SUPPORTED_EASES, "custom"]}
259
+ onChange={(next) => {
260
+ const easeKey = animation.keyframes ? "easeEach" : "ease";
261
+ if (next === "custom") {
262
+ const points = controlPointsForGsapEase(
263
+ easeName !== "none" ? easeName : "power2.out",
264
+ );
265
+ const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
266
+ onUpdateMeta(animation.id, { [easeKey]: `custom(${path})` });
267
+ } else {
268
+ onUpdateMeta(animation.id, { [easeKey]: next });
269
+ }
270
+ }}
271
+ />
272
+ <EaseCurveSection
273
+ ease={easeName}
274
+ duration={animation.duration}
275
+ onCustomEaseCommit={(customEase) => {
276
+ const easeKey = animation.keyframes ? "easeEach" : "ease";
277
+ onUpdateMeta(animation.id, { [easeKey]: customEase });
278
+ }}
279
+ />
280
+ </>
281
+ )}
461
282
  </>
462
283
  )}
463
284