@hyperframes/studio 0.7.6 → 0.7.8

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 (58) hide show
  1. package/dist/assets/{index-B_NnmU6q.js → index--lOAjFJl.js} +1 -1
  2. package/dist/assets/{index-ysftPins.js → index-BSyZKYmH.js} +204 -203
  3. package/dist/assets/{index-DsBhGFPe.js → index-Dgeszckd.js} +1 -1
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +5 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +3650 -2431
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +7 -1
  11. package/src/components/editor/AnimationCard.tsx +6 -0
  12. package/src/components/editor/DomEditOverlay.tsx +4 -12
  13. package/src/components/editor/EaseCurveSection.tsx +175 -99
  14. package/src/components/editor/GsapAnimationSection.tsx +2 -0
  15. package/src/components/editor/KeyframeEaseList.tsx +67 -3
  16. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  17. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  18. package/src/components/editor/PropertyPanel.tsx +23 -2
  19. package/src/components/editor/Transform3DCube.tsx +313 -0
  20. package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +11 -35
  22. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  23. package/src/components/editor/marqueeCommit.ts +63 -20
  24. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  25. package/src/components/editor/propertyPanelHelpers.ts +46 -18
  26. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  27. package/src/components/editor/transform3dProjection.test.ts +99 -0
  28. package/src/components/editor/transform3dProjection.ts +172 -0
  29. package/src/components/editor/useMotionPathData.ts +2 -1
  30. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  31. package/src/components/sidebar/AssetsTab.tsx +364 -266
  32. package/src/components/sidebar/AudioRow.tsx +202 -0
  33. package/src/components/sidebar/assetHelpers.ts +40 -0
  34. package/src/contexts/DomEditContext.tsx +8 -0
  35. package/src/hooks/gsapDragCommit.test.ts +9 -5
  36. package/src/hooks/gsapDragCommit.ts +129 -9
  37. package/src/hooks/gsapRuntimeBridge.ts +22 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  39. package/src/hooks/gsapRuntimeKeyframes.ts +63 -5
  40. package/src/hooks/gsapRuntimePatch.ts +162 -35
  41. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  42. package/src/hooks/useDomEditCommits.ts +0 -2
  43. package/src/hooks/useDomEditSession.ts +24 -2
  44. package/src/hooks/useDomEditWiring.ts +3 -0
  45. package/src/hooks/useDomGeometryCommits.ts +3 -31
  46. package/src/hooks/useGestureCommit.ts +0 -4
  47. package/src/hooks/useGsapAwareEditing.ts +31 -5
  48. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  49. package/src/hooks/useGsapScriptCommits.ts +0 -12
  50. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  51. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  52. package/src/hooks/useGsapTweenCache.ts +125 -42
  53. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  54. package/src/hooks/useRazorSplit.ts +0 -3
  55. package/src/utils/marqueeGeometry.test.ts +15 -98
  56. package/src/utils/marqueeGeometry.ts +1 -158
  57. package/dist/assets/index-wJZf6FK3.css +0 -1
  58. package/src/utils/editDebugLog.ts +0 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
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/core": "0.7.6",
49
- "@hyperframes/sdk": "0.7.6",
50
- "@hyperframes/player": "0.7.6"
48
+ "@hyperframes/player": "0.7.8",
49
+ "@hyperframes/core": "0.7.8",
50
+ "@hyperframes/sdk": "0.7.8"
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.6"
65
+ "@hyperframes/producer": "0.7.8"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "react": "19",
@@ -118,10 +118,12 @@ export function StudioRightPanel({
118
118
  handleGsapAddFromProperty,
119
119
  handleGsapRemoveFromProperty,
120
120
  commitAnimatedProperty,
121
+ commitAnimatedProperties,
121
122
  handleSetArcPath,
122
123
  handleUpdateArcSegment,
123
124
  handleUnroll,
124
125
  handleUpdateKeyframeEase,
126
+ handleSetAllKeyframeEases,
125
127
  handleGsapAddKeyframe,
126
128
  handleGsapRemoveKeyframe,
127
129
  handleGsapConvertToKeyframes,
@@ -268,14 +270,18 @@ export function StudioRightPanel({
268
270
  onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
269
271
  onAddGsapAnimation={handleGsapAddAnimation}
270
272
  onCommitAnimatedProperty={commitAnimatedProperty}
273
+ onCommitAnimatedProperties={commitAnimatedProperties}
271
274
  onAddKeyframe={handleGsapAddKeyframe}
272
275
  onRemoveKeyframe={handleGsapRemoveKeyframe}
273
- onConvertToKeyframes={handleGsapConvertToKeyframes}
276
+ onConvertToKeyframes={(animId, duration) =>
277
+ handleGsapConvertToKeyframes(animId, undefined, duration)
278
+ }
274
279
  onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
275
280
  onSetArcPath={handleSetArcPath}
276
281
  onUpdateArcSegment={handleUpdateArcSegment}
277
282
  onUnroll={handleUnroll}
278
283
  onUpdateKeyframeEase={handleUpdateKeyframeEase}
284
+ onSetAllKeyframeEases={handleSetAllKeyframeEases}
279
285
  recordingState={recordingState}
280
286
  recordingDuration={recordingDuration}
281
287
  onToggleRecording={onToggleRecording}
@@ -40,6 +40,7 @@ export const AnimationCard = memo(function AnimationCard({
40
40
  onSetArcPath,
41
41
  onUpdateArcSegment,
42
42
  onUpdateKeyframeEase,
43
+ onSetAllKeyframeEases,
43
44
  onUnroll,
44
45
  }: AnimationCardProps) {
45
46
  const [expanded, setExpanded] = useState(defaultExpanded);
@@ -249,6 +250,11 @@ export const AnimationCard = memo(function AnimationCard({
249
250
  expandedPct={expandedKfPct}
250
251
  onToggle={setExpandedKfPct}
251
252
  onEaseCommit={(pct, ease) => onUpdateKeyframeEase(animation.id, pct, ease)}
253
+ onApplyAll={
254
+ onSetAllKeyframeEases
255
+ ? (ease) => onSetAllKeyframeEases(animation.id, ease)
256
+ : undefined
257
+ }
252
258
  />
253
259
  ) : (
254
260
  <>
@@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "reac
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { type DomEditSelection } from "./domEditing";
4
4
  import { useMarqueeGestures } from "./marqueeCommit";
5
+ import { MarqueeOverlay } from "./MarqueeOverlay";
5
6
  import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
6
7
  import { collectDomEditLayerItems } from "./domEditingLayers";
7
8
  import { isElementComputedVisible } from "./domEditingElement";
@@ -101,6 +102,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
101
102
  const onMarqueeSelectRef = useRef(onMarqueeSelect);
102
103
  onMarqueeSelectRef.current = onMarqueeSelect;
103
104
 
105
+ // fallow-ignore-next-line complexity
104
106
  const selectionShapeStyles = (() => {
105
107
  const fallback = {
106
108
  borderRadius: 8 as string | number,
@@ -226,6 +228,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
226
228
  // outside the composition bounds so users can find them.
227
229
  const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
228
230
  const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
231
+ // fallow-ignore-next-line complexity
229
232
  useEffect(() => {
230
233
  const iframe = iframeRef.current;
231
234
  const overlay = overlayRef.current;
@@ -568,18 +571,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
568
571
  activeCompositionPathRef={activeCompositionPathRef}
569
572
  onSelectionChangeRef={onSelectionChangeRef}
570
573
  />
571
- {marquee.marqueeRect && (
572
- <div
573
- aria-hidden="true"
574
- className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
575
- style={{
576
- left: marquee.marqueeRect.left,
577
- top: marquee.marqueeRect.top,
578
- width: marquee.marqueeRect.width,
579
- height: marquee.marqueeRect.height,
580
- }}
581
- />
582
- )}
574
+ <MarqueeOverlay candidateRects={marquee.candidateRects} marqueeRect={marquee.marqueeRect} />
583
575
  <GridOverlay
584
576
  visible={gridVisible}
585
577
  spacing={gridSpacing}
@@ -2,14 +2,16 @@ import { useCallback, useRef, useState } from "react";
2
2
  import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
3
3
  import { roundToCenti } from "../../utils/rounding";
4
4
 
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.
5
7
  const PRESET_GRID_EASES = [
6
- "ae-ease",
7
- "ae-ease-in",
8
- "ae-ease-out",
9
8
  "none",
10
- "power2.out",
11
9
  "power2.in",
10
+ "power2.out",
11
+ "power2.inOut",
12
+ "back.in",
12
13
  "back.out",
14
+ "back.inOut",
13
15
  "expo.out",
14
16
  ] as const;
15
17
 
@@ -78,6 +80,37 @@ const EasePresetGrid = function EasePresetGrid({
78
80
 
79
81
  const round2 = roundToCenti;
80
82
 
83
+ // ── Graph geometry (Figma-style easing box) ─────────────────────────────────
84
+ // A geometrically-square unit plot ([0,1]×[0,1], equal X/Y scale so the curve
85
+ // isn't distorted), with fixed overshoot headroom above 1 and below 0 for
86
+ // back/elastic eases. The view is fixed (no per-curve zoom); handles are clamped
87
+ // to the visible range so they never drift off-screen.
88
+ const S = 184; // side of the unit (0..1) square, in viewBox units
89
+ const HR = 52; // overshoot headroom (top & bottom)
90
+ const PADH = 16; // horizontal breathing room
91
+ const SVGW = S + PADH * 2;
92
+ const SVGH = S + HR * 2;
93
+ const VMAX = 1 + HR / S; // top of visible view (progress overshoot headroom)
94
+ const VMIN = -HR / S; // bottom of visible view (undershoot headroom)
95
+ // Committed control points may extend PAST the visible view — heavy back/elastic
96
+ // presets reach ~1.55 / -0.55. Dragging clamps to this wider bound (cursor can
97
+ // leave the box via pointer capture) so those curves keep their fidelity instead
98
+ // of snapping to the view edge; the handle DOT is still clampView'd into view.
99
+ const DRAG_VMAX = 2;
100
+ const DRAG_VMIN = -1;
101
+ const ACCENT = "#3CE6AC";
102
+
103
+ type Pts = [number, number, number, number];
104
+
105
+ const xToSvg = (px: number) => PADH + S * px;
106
+ const yToSvg = (py: number) => HR + S * (1 - py);
107
+ const clampView = (py: number) => Math.max(VMIN, Math.min(VMAX, py));
108
+
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;
112
+ }
113
+
81
114
  export function EaseCurveSection({
82
115
  ease,
83
116
  duration,
@@ -90,25 +123,26 @@ export function EaseCurveSection({
90
123
  const isCustom = ease.startsWith("custom(");
91
124
  const curveFromPreset = EASE_CURVES[ease];
92
125
  const customPoints = isCustom ? parseCustomEaseFromString(ease) : null;
93
- const curve: [number, number, number, number] | null =
126
+ const curve: Pts | null =
94
127
  isCustom && customPoints
95
128
  ? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2]
96
129
  : (curveFromPreset ?? null);
97
130
 
98
- const [draft, setDraft] = useState<[number, number, number, number] | null>(null);
131
+ const [draft, setDraft] = useState<Pts | null>(null);
99
132
  const [progress, setProgress] = useState<number | null>(null);
133
+ const [hover, setHover] = useState<"p1" | "p2" | null>(null);
100
134
  const draggingRef = useRef<"p1" | "p2" | null>(null);
101
135
  const svgRef = useRef<SVGSVGElement | null>(null);
102
136
  const rafRef = useRef<number>(0);
103
137
 
104
138
  const play = useCallback(() => {
105
139
  const start = performance.now();
106
- const dur = 1000;
140
+ const dur = 1100;
107
141
  const tick = (now: number) => {
108
142
  const t = Math.min((now - start) / dur, 1);
109
143
  setProgress(t);
110
144
  if (t < 1) rafRef.current = requestAnimationFrame(tick);
111
- else setTimeout(() => setProgress(null), 400);
145
+ else setTimeout(() => setProgress(null), 450);
112
146
  };
113
147
  cancelAnimationFrame(rafRef.current);
114
148
  rafRef.current = requestAnimationFrame(tick);
@@ -118,27 +152,23 @@ export function EaseCurveSection({
118
152
  if (!active) return null;
119
153
  const [x1, y1, x2, y2] = active;
120
154
 
121
- const w = 200;
122
- const h = 100;
123
- const pad = 14;
124
- const gw = w - pad * 2;
125
- const gh = h - pad * 2;
126
-
127
- const toSvg = (px: number, py: number) => ({
128
- x: pad + gw * px,
129
- y: h - pad - gh * py,
130
- });
155
+ // Anchors + control handles. Handle *display* is clamped to the view so an
156
+ // extreme loaded overshoot rides the edge instead of disappearing.
157
+ const a0 = { x: xToSvg(0), y: yToSvg(0) };
158
+ const a1 = { x: xToSvg(1), y: yToSvg(1) };
159
+ const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) };
160
+ 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}`;
131
165
 
132
- const curvePath = `M${pad},${h - pad} C${toSvg(x1, y1).x},${toSvg(x1, y1).y} ${toSvg(x2, y2).x},${toSvg(x2, y2).y} ${w - pad},${pad}`;
133
-
134
- let dotX = pad;
135
- let dotY = h - pad;
166
+ let dot: { x: number; y: number } | null = null;
136
167
  if (progress !== null) {
137
- const t = progress;
138
- const mt = 1 - t;
139
- dotX = pad + gw * (mt * mt * mt * 0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t);
140
- dotY =
141
- h - pad - gh * (mt * mt * mt * 0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t);
168
+ dot = {
169
+ x: xToSvg(cubicAt(progress, 0, x1, x2, 1)),
170
+ y: yToSvg(cubicAt(progress, 0, y1, y2, 1)),
171
+ };
142
172
  }
143
173
 
144
174
  const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
@@ -153,12 +183,16 @@ export function EaseCurveSection({
153
183
  if (!draggingRef.current || !svgRef.current) return;
154
184
  e.preventDefault();
155
185
  const rect = svgRef.current.getBoundingClientRect();
156
- const sx = ((e.clientX - rect.left) / rect.width) * w;
157
- const sy = ((e.clientY - rect.top) / rect.height) * h;
158
- const px = Math.max(0, Math.min(1, (sx - pad) / gw));
159
- const py = Math.max(-1, Math.min(2, (h - pad - sy) / gh));
186
+ const sx = ((e.clientX - rect.left) / rect.width) * SVGW;
187
+ const sy = ((e.clientY - rect.top) / rect.height) * SVGH;
188
+ // px is clamped to [0,1] on purpose: a cubic-bezier ease must be monotonic in
189
+ // time (handle1.x ≤ handle2.x), so handles can't pass each other or invert.
190
+ const px = Math.max(0, Math.min(1, (sx - PADH) / S));
191
+ // py uses the WIDER drag bound (not clampView), so dragging keeps overshoot
192
+ // 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));
160
194
  const prev = draft ?? [x1, y1, x2, y2];
161
- const next: [number, number, number, number] =
195
+ const next: Pts =
162
196
  draggingRef.current === "p1"
163
197
  ? [round2(px), round2(py), prev[2], prev[3]]
164
198
  : [prev[0], prev[1], round2(px), round2(py)];
@@ -173,11 +207,12 @@ export function EaseCurveSection({
173
207
  setDraft(null);
174
208
  };
175
209
 
176
- const p1 = toSvg(x1, y1);
177
- const p2 = toSvg(x2, y2);
178
- const start = toSvg(0, 0);
179
- const end = toSvg(1, 1);
210
+ const top = yToSvg(1);
211
+ const bottom = yToSvg(0);
212
+ const left = xToSvg(0);
213
+ const right = xToSvg(1);
180
214
  const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease);
215
+ const bezierText = `${x1} · ${y1} · ${x2} · ${y2}`;
181
216
 
182
217
  return (
183
218
  <div className="rounded-lg bg-neutral-900/50 p-2">
@@ -193,98 +228,139 @@ export function EaseCurveSection({
193
228
  </button>
194
229
  </div>
195
230
  <div
196
- className="overflow-hidden rounded pt-[72px] -mt-[72px]"
197
- style={{ aspectRatio: `${w}/${h}` }}
231
+ className="mx-auto overflow-hidden rounded-md border border-white/5 bg-black/20"
232
+ style={{ aspectRatio: `${SVGW} / ${SVGH}`, width: "100%", maxWidth: 230 }}
198
233
  >
199
234
  <svg
200
235
  ref={svgRef}
201
236
  width="100%"
202
237
  height="100%"
203
- viewBox={`0 0 ${w} ${h}`}
238
+ viewBox={`0 0 ${SVGW} ${SVGH}`}
204
239
  preserveAspectRatio="none"
205
- style={{ overflow: "visible" }}
206
240
  className="touch-none select-none"
207
241
  onPointerMove={handlePointerMove}
208
242
  onPointerUp={handlePointerUp}
209
243
  onPointerCancel={handlePointerUp}
210
244
  >
211
- <line
212
- x1={pad}
213
- y1={h - pad}
214
- x2={w - pad}
215
- y2={h - pad}
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"
216
277
  stroke="white"
217
- strokeOpacity="0.06"
218
- strokeWidth="0.5"
278
+ strokeOpacity="0.1"
279
+ strokeWidth="1"
219
280
  />
281
+ {/* Linear reference diagonal */}
220
282
  <line
221
- x1={pad}
222
- y1={pad}
223
- x2={pad}
224
- y2={h - pad}
283
+ x1={a0.x}
284
+ y1={a0.y}
285
+ x2={a1.x}
286
+ y2={a1.y}
225
287
  stroke="white"
226
- strokeOpacity="0.06"
227
- strokeWidth="0.5"
288
+ strokeOpacity="0.08"
289
+ strokeWidth="1"
290
+ strokeDasharray="3 4"
228
291
  />
292
+ {/* Tangent handle lines */}
229
293
  <line
230
- x1={start.x}
231
- y1={start.y}
294
+ x1={a0.x}
295
+ y1={a0.y}
232
296
  x2={p1.x}
233
297
  y2={p1.y}
234
- stroke="rgba(52,211,153,0.25)"
235
- strokeWidth="1"
298
+ stroke={ACCENT}
299
+ strokeOpacity="0.5"
300
+ strokeWidth="1.5"
236
301
  />
237
302
  <line
238
- x1={end.x}
239
- y1={end.y}
303
+ x1={a1.x}
304
+ y1={a1.y}
240
305
  x2={p2.x}
241
306
  y2={p2.y}
242
- stroke="rgba(45,212,191,0.25)"
243
- strokeWidth="1"
307
+ stroke={ACCENT}
308
+ strokeOpacity="0.5"
309
+ strokeWidth="1.5"
244
310
  />
245
- <path d={curvePath} fill="none" stroke="#3CE6AC" strokeWidth="2" strokeLinecap="round" />
246
- {progress !== null && <circle cx={dotX} cy={dotY} r="4" fill="#3CE6AC" />}
247
- <circle
248
- cx={p1.x}
249
- cy={p1.y}
250
- r="5"
251
- fill="#0a0a1a"
252
- stroke="#3CE6AC"
253
- strokeWidth="2"
254
- className="cursor-grab active:cursor-grabbing"
255
- onPointerDown={(e) => handlePointerDown("p1", e)}
256
- />
257
- <circle
258
- cx={p2.x}
259
- cy={p2.y}
260
- r="5"
261
- fill="#0a0a1a"
262
- stroke="#3CE6AC"
263
- strokeWidth="2"
264
- className="cursor-grab active:cursor-grabbing"
265
- onPointerDown={(e) => handlePointerDown("p2", e)}
266
- />
267
- {duration != null && duration > 0 && (
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 && (
268
318
  <>
269
- <text x={pad} y={h - 1} textAnchor="start" className="fill-neutral-600 text-[8px]">
270
- 0s
271
- </text>
272
- <text
273
- x={pad + gw / 2}
274
- y={h - 1}
275
- textAnchor="middle"
276
- className="fill-neutral-600 text-[8px]"
277
- >
278
- {(duration / 2).toFixed(1)}s
279
- </text>
280
- <text x={w - pad} y={h - 1} textAnchor="end" className="fill-neutral-600 text-[8px]">
281
- {duration}s
282
- </text>
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} />
283
321
  </>
284
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))}
335
+ />
336
+ <circle
337
+ cx={pt.x}
338
+ cy={pt.y}
339
+ r={hover === key || draggingRef.current === key ? 7 : 5.5}
340
+ fill="#0a0a1a"
341
+ stroke={ACCENT}
342
+ strokeWidth="2.5"
343
+ className="pointer-events-none transition-[r]"
344
+ />
345
+ </g>
346
+ ))}
285
347
  </svg>
286
348
  </div>
287
- <p className="mt-1 text-center text-[10px] text-neutral-500">{label}</p>
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>
288
364
  </div>
289
365
  );
290
366
  }
@@ -31,6 +31,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
31
31
  onSetArcPath,
32
32
  onUpdateArcSegment,
33
33
  onUpdateKeyframeEase,
34
+ onSetAllKeyframeEases,
34
35
  onUnroll,
35
36
  }: GsapAnimationSectionProps) {
36
37
  const [addMenuOpen, setAddMenuOpen] = useState(false);
@@ -70,6 +71,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
70
71
  onSetArcPath={onSetArcPath}
71
72
  onUpdateArcSegment={onUpdateArcSegment}
72
73
  onUpdateKeyframeEase={onUpdateKeyframeEase}
74
+ onSetAllKeyframeEases={onSetAllKeyframeEases}
73
75
  onUnroll={onUnroll}
74
76
  />
75
77
  ))}
@@ -2,24 +2,88 @@ import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
2
2
  import { EASE_LABELS } from "./gsapAnimationConstants";
3
3
  import { EaseCurveSection } from "./EaseCurveSection";
4
4
 
5
+ // The full GSAP easing vocabulary offered by the "Set all…" bulk control —
6
+ // every standard family in in/out/inOut, so authors aren't limited to a curated
7
+ // few. All are valid GSAP runtime eases; the non-cubic families (sine/circ/
8
+ // elastic/bounce) approximate in the per-segment curve preview.
9
+ const APPLY_ALL_EASES = [
10
+ "none",
11
+ "power1.in",
12
+ "power1.out",
13
+ "power1.inOut",
14
+ "power2.in",
15
+ "power2.out",
16
+ "power2.inOut",
17
+ "power3.in",
18
+ "power3.out",
19
+ "power3.inOut",
20
+ "power4.in",
21
+ "power4.out",
22
+ "power4.inOut",
23
+ "sine.in",
24
+ "sine.out",
25
+ "sine.inOut",
26
+ "expo.in",
27
+ "expo.out",
28
+ "expo.inOut",
29
+ "circ.in",
30
+ "circ.out",
31
+ "circ.inOut",
32
+ "back.in",
33
+ "back.out",
34
+ "back.inOut",
35
+ "elastic.in",
36
+ "elastic.out",
37
+ "elastic.inOut",
38
+ "bounce.in",
39
+ "bounce.out",
40
+ "bounce.inOut",
41
+ ] as const;
42
+
5
43
  export function KeyframeEaseList({
6
44
  keyframes,
7
45
  globalEase,
8
46
  expandedPct,
9
47
  onToggle,
10
48
  onEaseCommit,
49
+ onApplyAll,
11
50
  }: {
12
51
  keyframes: GsapPercentageKeyframe[];
13
52
  globalEase: string;
14
53
  expandedPct: number | null;
15
54
  onToggle: (pct: number | null) => void;
16
55
  onEaseCommit: (pct: number, ease: string) => void;
56
+ /** Apply one ease to every segment at once (clears per-segment overrides). */
57
+ onApplyAll?: (ease: string) => void;
17
58
  }) {
18
59
  return (
19
60
  <div className="space-y-1">
20
- <p className="text-[9px] font-semibold uppercase tracking-wider text-neutral-500">
21
- Per-keyframe easing
22
- </p>
61
+ <div className="flex items-center gap-2">
62
+ <p className="text-[9px] font-semibold uppercase tracking-wider text-neutral-500">
63
+ Per-keyframe easing
64
+ </p>
65
+ {onApplyAll && (
66
+ <select
67
+ aria-label="Apply one ease to all segments"
68
+ title="Apply one ease to every segment (clears per-segment overrides)"
69
+ value=""
70
+ onChange={(e) => {
71
+ const next = e.target.value;
72
+ if (next) onApplyAll(next);
73
+ }}
74
+ className="ml-auto cursor-pointer rounded bg-neutral-800 px-1.5 py-0.5 text-[9px] text-neutral-300 outline-none hover:bg-neutral-700 focus:ring-1 focus:ring-panel-accent/40"
75
+ >
76
+ <option value="" disabled>
77
+ Set all…
78
+ </option>
79
+ {APPLY_ALL_EASES.map((name) => (
80
+ <option key={name} value={name}>
81
+ {EASE_LABELS[name] ?? name}
82
+ </option>
83
+ ))}
84
+ </select>
85
+ )}
86
+ </div>
23
87
  {keyframes.map((kf, i) => {
24
88
  if (i === 0) return null;
25
89
  const segEase = kf.ease ?? globalEase;
@@ -0,0 +1,40 @@
1
+ import type { Rect } from "../../utils/marqueeGeometry";
2
+
3
+ interface MarqueeOverlayProps {
4
+ /** Elements the marquee currently intersects — outlined live before mouse-up. */
5
+ candidateRects: Rect[];
6
+ /** The marquee drag rectangle itself, or null when not marquee-selecting. */
7
+ marqueeRect: Rect | null;
8
+ }
9
+
10
+ /**
11
+ * The marquee selection visuals: a live "candidate" outline on each element the
12
+ * box currently touches, plus the dashed drag rectangle. Extracted from
13
+ * DomEditOverlay to keep that file under the 600-line cap.
14
+ */
15
+ export function MarqueeOverlay({ candidateRects, marqueeRect }: MarqueeOverlayProps) {
16
+ return (
17
+ <>
18
+ {candidateRects.map((r, i) => (
19
+ <div
20
+ key={i}
21
+ aria-hidden="true"
22
+ className="pointer-events-none absolute rounded-sm border border-studio-accent bg-studio-accent/5"
23
+ style={{ left: r.left, top: r.top, width: r.width, height: r.height }}
24
+ />
25
+ ))}
26
+ {marqueeRect && (
27
+ <div
28
+ aria-hidden="true"
29
+ className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
30
+ style={{
31
+ left: marqueeRect.left,
32
+ top: marqueeRect.top,
33
+ width: marqueeRect.width,
34
+ height: marqueeRect.height,
35
+ }}
36
+ />
37
+ )}
38
+ </>
39
+ );
40
+ }