@hyperframes/studio 0.6.85 → 0.6.87

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 (88) hide show
  1. package/dist/assets/{hyperframes-player-DRpY3xHh.js → hyperframes-player-0esDKGRk.js} +1 -1
  2. package/dist/assets/index-BA19FAPN.js +143 -0
  3. package/dist/assets/index-CGlIm_-E.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +159 -6
  7. package/src/components/StudioHeader.tsx +20 -7
  8. package/src/components/StudioPreviewArea.tsx +6 -1
  9. package/src/components/StudioRightPanel.tsx +13 -0
  10. package/src/components/StudioToast.tsx +47 -7
  11. package/src/components/TimelineToolbar.tsx +12 -122
  12. package/src/components/editor/AnimationCard.tsx +64 -10
  13. package/src/components/editor/ArcPathControls.tsx +131 -0
  14. package/src/components/editor/BorderRadiusEditor.tsx +209 -0
  15. package/src/components/editor/DomEditOverlay.tsx +70 -11
  16. package/src/components/editor/DopesheetStrip.tsx +141 -0
  17. package/src/components/editor/EaseCurveSection.tsx +82 -7
  18. package/src/components/editor/GestureTrailOverlay.tsx +132 -0
  19. package/src/components/editor/GsapAnimationSection.tsx +14 -1
  20. package/src/components/editor/KeyframeDiamond.tsx +27 -12
  21. package/src/components/editor/LayersPanel.tsx +14 -12
  22. package/src/components/editor/MotionPathOverlay.tsx +146 -0
  23. package/src/components/editor/PropertyPanel.tsx +196 -66
  24. package/src/components/editor/SourceEditor.tsx +0 -1
  25. package/src/components/editor/StaggerControls.tsx +61 -0
  26. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  27. package/src/components/editor/domEditOverlayGeometry.ts +2 -1
  28. package/src/components/editor/domEditing.test.ts +43 -0
  29. package/src/components/editor/domEditing.ts +2 -0
  30. package/src/components/editor/domEditingElement.ts +25 -2
  31. package/src/components/editor/domEditingLayers.test.ts +78 -0
  32. package/src/components/editor/domEditingLayers.ts +33 -13
  33. package/src/components/editor/domEditingTypes.ts +1 -0
  34. package/src/components/editor/manualEditingAvailability.ts +1 -1
  35. package/src/components/editor/manualEdits.ts +3 -0
  36. package/src/components/editor/manualEditsDom.ts +23 -5
  37. package/src/components/editor/manualOffsetDrag.ts +59 -0
  38. package/src/components/editor/panelTokens.ts +10 -0
  39. package/src/components/editor/propertyPanelColor.tsx +2 -2
  40. package/src/components/editor/propertyPanelFill.tsx +1 -1
  41. package/src/components/editor/propertyPanelHelpers.ts +18 -2
  42. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  43. package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
  44. package/src/components/editor/propertyPanelSections.tsx +4 -6
  45. package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
  46. package/src/components/editor/useDomEditOverlayRects.ts +46 -2
  47. package/src/components/renders/RenderQueue.tsx +121 -100
  48. package/src/components/renders/RenderQueueItem.tsx +13 -13
  49. package/src/contexts/DomEditContext.tsx +12 -0
  50. package/src/contexts/FileManagerContext.tsx +3 -0
  51. package/src/contexts/StudioContext.tsx +0 -4
  52. package/src/hooks/gsapKeyframeCommit.ts +92 -0
  53. package/src/hooks/gsapRuntimeBridge.ts +147 -85
  54. package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
  55. package/src/hooks/gsapRuntimePreview.ts +19 -0
  56. package/src/hooks/useAppHotkeys.ts +18 -0
  57. package/src/hooks/useAskAgentModal.ts +2 -4
  58. package/src/hooks/useDomEditCommits.ts +11 -17
  59. package/src/hooks/useDomEditSession.ts +47 -4
  60. package/src/hooks/useEnableKeyframes.ts +171 -0
  61. package/src/hooks/useFileManager.ts +7 -0
  62. package/src/hooks/useGestureRecording.ts +340 -0
  63. package/src/hooks/useGsapScriptCommits.ts +171 -35
  64. package/src/hooks/useGsapSelectionHandlers.ts +27 -8
  65. package/src/hooks/useGsapTweenCache.ts +169 -11
  66. package/src/hooks/useKeyframeKeyboard.ts +103 -0
  67. package/src/hooks/useStudioContextValue.ts +5 -4
  68. package/src/hooks/useStudioUrlState.ts +1 -2
  69. package/src/hooks/useTimelineEditing.ts +50 -3
  70. package/src/hooks/useToast.ts +6 -1
  71. package/src/player/components/ShortcutsPanel.tsx +40 -0
  72. package/src/player/components/TimelineClipDiamonds.tsx +3 -3
  73. package/src/player/components/TimelinePropertyRows.tsx +120 -0
  74. package/src/player/lib/timelineDOM.test.ts +55 -0
  75. package/src/player/lib/timelineDOM.ts +13 -0
  76. package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
  77. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  78. package/src/player/store/playerStore.ts +43 -0
  79. package/src/utils/audioBeatDetection.ts +58 -0
  80. package/src/utils/globalTimeCompiler.test.ts +169 -0
  81. package/src/utils/globalTimeCompiler.ts +77 -0
  82. package/src/utils/gsapSoftReload.ts +30 -10
  83. package/src/utils/keyframeSnapping.test.ts +74 -0
  84. package/src/utils/keyframeSnapping.ts +63 -0
  85. package/src/utils/rdpSimplify.ts +183 -0
  86. package/src/utils/sourcePatcher.ts +2 -0
  87. package/dist/assets/index-DHcptK1_.css +0 -1
  88. package/dist/assets/index-DtSCUvYQ.js +0 -140
@@ -90,6 +90,29 @@ export const DomEditOverlay = memo(function DomEditOverlay({
90
90
  }: DomEditOverlayProps) {
91
91
  const overlayRef = useRef<HTMLDivElement | null>(null);
92
92
  const boxRef = useRef<HTMLDivElement | null>(null);
93
+
94
+ const selectionShapeStyles = (() => {
95
+ const fallback = {
96
+ borderRadius: 4 as string | number,
97
+ clipPath: undefined as string | undefined,
98
+ };
99
+ if (!selection?.element) return fallback;
100
+ try {
101
+ const tag = selection.element.tagName.toLowerCase();
102
+ if (tag === "svg" || tag === "img" || tag === "video" || tag === "canvas") return fallback;
103
+ const win = selection.element.ownerDocument.defaultView;
104
+ if (!win) return fallback;
105
+ const cs = win.getComputedStyle(selection.element);
106
+ const br = cs.borderRadius;
107
+ const cp = cs.clipPath;
108
+ return {
109
+ borderRadius: br && br !== "0px" ? br : 4,
110
+ clipPath: cp && cp !== "none" ? cp : undefined,
111
+ };
112
+ } catch {
113
+ return fallback;
114
+ }
115
+ })();
93
116
  const gestureRef = useRef<GestureState | null>(null);
94
117
  const groupGestureRef = useRef<GroupGestureState | null>(null);
95
118
  const blockedMoveRef = useRef<BlockedMoveState | null>(null);
@@ -134,6 +157,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
134
157
  groupOverlayItems,
135
158
  groupOverlayItemsRef,
136
159
  setGroupOverlayItems,
160
+ childRects,
137
161
  } = useDomEditOverlayRects({
138
162
  iframeRef,
139
163
  overlayRef,
@@ -228,6 +252,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
228
252
  groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset);
229
253
 
230
254
  const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
255
+ if (!allowCanvasMovement) return;
231
256
  if (suppressNextOverlayMouseDownRef.current) {
232
257
  suppressNextOverlayMouseDownRef.current = false;
233
258
  suppressNextBoxMouseDownRef.current = false;
@@ -288,6 +313,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
288
313
  };
289
314
 
290
315
  const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
316
+ if (!allowCanvasMovement) return;
291
317
  if (gestureRef.current || groupGestureRef.current) return;
292
318
  if (suppressNextBoxClickRef.current) {
293
319
  suppressNextBoxClickRef.current = false;
@@ -320,20 +346,37 @@ export const DomEditOverlay = memo(function DomEditOverlay({
320
346
  onPointerUp={gestures.onPointerUp}
321
347
  onPointerCancel={() => gestures.clearPointerState(selectionRef)}
322
348
  >
323
- {hoverSelection && hoverRect && (
349
+ {hoverSelection && hoverRect && compRect.width > 0 && (
324
350
  <div
325
351
  aria-hidden="true"
326
352
  data-dom-edit-hover-box="true"
327
- className="pointer-events-none absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
328
- style={{
329
- left: hoverRect.left,
330
- top: hoverRect.top,
331
- width: hoverRect.width,
332
- height: hoverRect.height,
333
- }}
353
+ className="pointer-events-none absolute border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
354
+ style={(() => {
355
+ let br: string | number = 4;
356
+ let cp: string | undefined;
357
+ try {
358
+ const el = hoverSelection.element;
359
+ const tag = el.tagName.toLowerCase();
360
+ if (tag !== "svg" && tag !== "img" && tag !== "video" && tag !== "canvas") {
361
+ const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
362
+ if (cs?.borderRadius && cs.borderRadius !== "0px") br = cs.borderRadius;
363
+ if (cs?.clipPath && cs.clipPath !== "none") cp = cs.clipPath;
364
+ }
365
+ } catch {
366
+ /* cross-origin guard */
367
+ }
368
+ return {
369
+ left: hoverRect.left,
370
+ top: hoverRect.top,
371
+ width: hoverRect.width,
372
+ height: hoverRect.height,
373
+ borderRadius: br,
374
+ clipPath: cp,
375
+ };
376
+ })()}
334
377
  />
335
378
  )}
336
- {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && (
379
+ {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
337
380
  <>
338
381
  {groupOverlayItems.map((item) => (
339
382
  <div
@@ -367,7 +410,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
367
410
  />
368
411
  </>
369
412
  )}
370
- {!hasGroupSelection && selection && overlayRect && (
413
+ {!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
371
414
  <>
372
415
  {allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
373
416
  <div
@@ -398,12 +441,14 @@ export const DomEditOverlay = memo(function DomEditOverlay({
398
441
  key={selectionKey}
399
442
  ref={boxRef}
400
443
  data-dom-edit-selection-box="true"
401
- className="pointer-events-auto absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
444
+ className={`pointer-events-auto absolute ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
402
445
  style={{
403
446
  left: overlayRect.left,
404
447
  top: overlayRect.top,
405
448
  width: overlayRect.width,
406
449
  height: overlayRect.height,
450
+ borderRadius: selectionShapeStyles.borderRadius,
451
+ clipPath: selectionShapeStyles.clipPath,
407
452
  cursor:
408
453
  allowCanvasMovement && selection.capabilities.canApplyManualOffset
409
454
  ? "move"
@@ -441,6 +486,20 @@ export const DomEditOverlay = memo(function DomEditOverlay({
441
486
  </div>
442
487
  </>
443
488
  )}
489
+ {childRects.length > 0 &&
490
+ compRect.width > 0 &&
491
+ childRects.map((cr, i) => (
492
+ <div
493
+ key={i}
494
+ className="pointer-events-none absolute border border-dashed border-white/20 rounded-sm"
495
+ style={{
496
+ left: cr.left,
497
+ top: cr.top,
498
+ width: cr.width,
499
+ height: cr.height,
500
+ }}
501
+ />
502
+ ))}
444
503
  <GridOverlay
445
504
  visible={gridVisible}
446
505
  spacing={gridSpacing}
@@ -0,0 +1,141 @@
1
+ import { memo, useCallback, useRef } from "react";
2
+
3
+ interface DopesheetKeyframe {
4
+ percentage: number;
5
+ properties: Record<string, number | string>;
6
+ ease?: string;
7
+ }
8
+
9
+ interface DopesheetStripProps {
10
+ keyframes: DopesheetKeyframe[];
11
+ selectedPercentage: number | null;
12
+ currentPercentage: number;
13
+ accentColor?: string;
14
+ onSelectKeyframe: (percentage: number) => void;
15
+ onDragKeyframe?: (fromPct: number, toPct: number) => void;
16
+ }
17
+
18
+ const DIAMOND_SIZE = 8;
19
+ const HALF = DIAMOND_SIZE / 2;
20
+ const STRIP_HEIGHT = 20;
21
+ const PADDING_X = 8;
22
+
23
+ export const DopesheetStrip = memo(function DopesheetStrip({
24
+ keyframes,
25
+ selectedPercentage,
26
+ currentPercentage,
27
+ accentColor = "#3CE6AC",
28
+ onSelectKeyframe,
29
+ onDragKeyframe,
30
+ }: DopesheetStripProps) {
31
+ const containerRef = useRef<HTMLDivElement>(null);
32
+ const dragRef = useRef<{ startX: number; startPct: number } | null>(null);
33
+
34
+ const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
35
+
36
+ const handlePointerDown = useCallback(
37
+ (e: React.PointerEvent, pct: number) => {
38
+ if (e.button !== 0) return;
39
+ e.stopPropagation();
40
+ const startX = e.clientX;
41
+
42
+ const handleMove = (me: PointerEvent) => {
43
+ if (Math.abs(me.clientX - startX) > 4) {
44
+ dragRef.current = { startX, startPct: pct };
45
+ }
46
+ };
47
+
48
+ const handleUp = (ue: PointerEvent) => {
49
+ document.removeEventListener("pointermove", handleMove);
50
+ document.removeEventListener("pointerup", handleUp);
51
+ if (dragRef.current && containerRef.current && onDragKeyframe) {
52
+ const rect = containerRef.current.getBoundingClientRect();
53
+ const usableWidth = rect.width - PADDING_X * 2;
54
+ const dx = ue.clientX - dragRef.current.startX;
55
+ const dpct = (dx / usableWidth) * 100;
56
+ const newPct = Math.max(0, Math.min(100, Math.round((pct + dpct) * 10) / 10));
57
+ if (newPct !== pct) onDragKeyframe(pct, newPct);
58
+ } else {
59
+ onSelectKeyframe(pct);
60
+ }
61
+ dragRef.current = null;
62
+ };
63
+
64
+ document.addEventListener("pointermove", handleMove);
65
+ document.addEventListener("pointerup", handleUp);
66
+ },
67
+ [onSelectKeyframe, onDragKeyframe],
68
+ );
69
+
70
+ return (
71
+ <div
72
+ ref={containerRef}
73
+ className="relative w-full rounded-md bg-neutral-900/60 border border-neutral-800/50"
74
+ style={{ height: STRIP_HEIGHT }}
75
+ >
76
+ {/* Playhead indicator */}
77
+ <div
78
+ className="absolute top-0 bottom-0 w-px bg-white/30"
79
+ style={{
80
+ left: `${PADDING_X + (currentPercentage / 100) * (100 - PADDING_X * 2)}%`,
81
+ marginLeft: -0.5,
82
+ }}
83
+ />
84
+
85
+ {/* Diamond markers */}
86
+ <svg
87
+ className="absolute inset-0 w-full"
88
+ style={{ height: STRIP_HEIGHT }}
89
+ viewBox={`0 0 100 ${STRIP_HEIGHT}`}
90
+ preserveAspectRatio="none"
91
+ >
92
+ {sorted.map((kf) => {
93
+ const x = PADDING_X + (kf.percentage / 100) * (100 - PADDING_X * 2);
94
+ const y = STRIP_HEIGHT / 2;
95
+ const isSelected =
96
+ selectedPercentage !== null && Math.abs(kf.percentage - selectedPercentage) < 0.5;
97
+ const isHold = kf.ease === "steps(1)";
98
+ const fillColor = isSelected ? accentColor : "#737373";
99
+
100
+ return (
101
+ <g
102
+ key={kf.percentage}
103
+ onPointerDown={(e) => handlePointerDown(e, kf.percentage)}
104
+ style={{ cursor: "pointer" }}
105
+ >
106
+ {isHold ? (
107
+ <rect
108
+ x={x - HALF}
109
+ y={y - HALF}
110
+ width={DIAMOND_SIZE}
111
+ height={DIAMOND_SIZE}
112
+ fill={fillColor}
113
+ />
114
+ ) : (
115
+ <rect
116
+ x={x - HALF}
117
+ y={y - HALF}
118
+ width={DIAMOND_SIZE}
119
+ height={DIAMOND_SIZE}
120
+ fill={fillColor}
121
+ transform={`rotate(45, ${x}, ${y})`}
122
+ />
123
+ )}
124
+ </g>
125
+ );
126
+ })}
127
+ </svg>
128
+
129
+ {/* Time labels */}
130
+ {sorted.length > 0 && (
131
+ <div
132
+ className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[8px] text-neutral-600 pointer-events-none"
133
+ style={{ lineHeight: "10px" }}
134
+ >
135
+ <span>{sorted[0].percentage}%</span>
136
+ {sorted.length > 1 && <span>{sorted[sorted.length - 1].percentage}%</span>}
137
+ </div>
138
+ )}
139
+ </div>
140
+ );
141
+ });
@@ -1,6 +1,80 @@
1
- import { useCallback, useRef, useState } from "react";
1
+ import { memo, useCallback, useRef, useState } from "react";
2
2
  import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
3
3
 
4
+ const PRESET_GRID_EASES = [
5
+ "none",
6
+ "power2.out",
7
+ "power2.in",
8
+ "power2.inOut",
9
+ "power3.out",
10
+ "back.out",
11
+ "expo.out",
12
+ "elastic.out",
13
+ ] as const;
14
+
15
+ function MiniCurveSvg({
16
+ curve,
17
+ active,
18
+ }: {
19
+ curve: [number, number, number, number];
20
+ active: boolean;
21
+ }) {
22
+ const [x1, y1, x2, y2] = curve;
23
+ const s = 24;
24
+ const p = 3;
25
+ const g = s - p * 2;
26
+ const sx = (px: number) => p + g * px;
27
+ const sy = (py: number) => s - p - g * py;
28
+ const d = `M${p},${s - p} C${sx(x1)},${sy(y1)} ${sx(x2)},${sy(y2)} ${s - p},${p}`;
29
+ return (
30
+ <svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
31
+ <path
32
+ d={d}
33
+ fill="none"
34
+ stroke={active ? "#3CE6AC" : "#737373"}
35
+ strokeWidth="1.5"
36
+ strokeLinecap="round"
37
+ />
38
+ </svg>
39
+ );
40
+ }
41
+
42
+ const EasePresetGrid = memo(function EasePresetGrid({
43
+ currentEase,
44
+ onSelect,
45
+ }: {
46
+ currentEase: string;
47
+ onSelect: (ease: string) => void;
48
+ }) {
49
+ return (
50
+ <div className="grid grid-cols-4 gap-1 mb-2">
51
+ {PRESET_GRID_EASES.map((name) => {
52
+ const curve = EASE_CURVES[name];
53
+ if (!curve) return null;
54
+ const isActive = currentEase === name;
55
+ return (
56
+ <button
57
+ key={name}
58
+ type="button"
59
+ onClick={() => onSelect(name)}
60
+ className={`flex flex-col items-center gap-0.5 rounded-md p-1 transition-colors ${
61
+ isActive ? "bg-panel-accent/10 ring-1 ring-panel-accent/30" : "hover:bg-neutral-800"
62
+ }`}
63
+ title={EASE_LABELS[name] ?? name}
64
+ >
65
+ <MiniCurveSvg curve={curve} active={isActive} />
66
+ <span
67
+ className={`text-[8px] leading-none ${isActive ? "text-panel-accent" : "text-neutral-500"}`}
68
+ >
69
+ {(EASE_LABELS[name] ?? name).split(" ").slice(0, 2).join(" ")}
70
+ </span>
71
+ </button>
72
+ );
73
+ })}
74
+ </div>
75
+ );
76
+ });
77
+
4
78
  function round2(n: number): number {
5
79
  return Math.round(n * 100) / 100;
6
80
  }
@@ -108,12 +182,13 @@ export function EaseCurveSection({
108
182
 
109
183
  return (
110
184
  <div className="rounded-lg bg-neutral-900/50 p-2">
185
+ <EasePresetGrid currentEase={ease} onSelect={(name) => onCustomEaseCommit(name)} />
111
186
  <div className="mb-1.5 flex items-center justify-between">
112
187
  <span className="text-[10px] font-medium text-neutral-500">Speed curve</span>
113
188
  <button
114
189
  type="button"
115
190
  onClick={play}
116
- className="rounded px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 transition-colors hover:bg-emerald-500/10"
191
+ className="rounded px-1.5 py-0.5 text-[10px] font-medium text-panel-accent transition-colors hover:bg-panel-accent/10"
117
192
  >
118
193
  {progress !== null ? "Playing…" : "Preview"}
119
194
  </button>
@@ -165,17 +240,17 @@ export function EaseCurveSection({
165
240
  y1={end.y}
166
241
  x2={p2.x}
167
242
  y2={p2.y}
168
- stroke="rgba(52,211,153,0.25)"
243
+ stroke="rgba(45,212,191,0.25)"
169
244
  strokeWidth="1"
170
245
  />
171
- <path d={curvePath} fill="none" stroke="#34d399" strokeWidth="2" strokeLinecap="round" />
172
- {progress !== null && <circle cx={dotX} cy={dotY} r="4" fill="#34d399" />}
246
+ <path d={curvePath} fill="none" stroke="#3CE6AC" strokeWidth="2" strokeLinecap="round" />
247
+ {progress !== null && <circle cx={dotX} cy={dotY} r="4" fill="#3CE6AC" />}
173
248
  <circle
174
249
  cx={p1.x}
175
250
  cy={p1.y}
176
251
  r="5"
177
252
  fill="#0a0a1a"
178
- stroke="#34d399"
253
+ stroke="#3CE6AC"
179
254
  strokeWidth="2"
180
255
  className="cursor-grab active:cursor-grabbing"
181
256
  onPointerDown={(e) => handlePointerDown("p1", e)}
@@ -185,7 +260,7 @@ export function EaseCurveSection({
185
260
  cy={p2.y}
186
261
  r="5"
187
262
  fill="#0a0a1a"
188
- stroke="#34d399"
263
+ stroke="#3CE6AC"
189
264
  strokeWidth="2"
190
265
  className="cursor-grab active:cursor-grabbing"
191
266
  onPointerDown={(e) => handlePointerDown("p2", e)}
@@ -0,0 +1,132 @@
1
+ import { memo, useMemo } from "react";
2
+ import type { GestureSample } from "../../hooks/useGestureRecording";
3
+
4
+ interface GestureTrailOverlayProps {
5
+ samples: GestureSample[];
6
+ sampleCount?: number;
7
+ trail?: Array<{ x: number; y: number }>;
8
+ simplifiedPoints?: Map<number, Record<string, number>>;
9
+ canvasRect: { left: number; top: number; width: number; height: number };
10
+ compositionSize?: { width: number; height: number };
11
+ mode: "recording" | "preview";
12
+ accentColor?: string;
13
+ }
14
+
15
+ export const GestureTrailOverlay = memo(function GestureTrailOverlay({
16
+ samples,
17
+ sampleCount,
18
+ trail,
19
+ simplifiedPoints,
20
+ canvasRect,
21
+ compositionSize,
22
+ mode,
23
+ accentColor = "#3CE6AC",
24
+ }: GestureTrailOverlayProps) {
25
+ const trailPoints = useMemo(() => {
26
+ if (trail && trail.length > 1) {
27
+ return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" ");
28
+ }
29
+ if (samples.length === 0) return "";
30
+ return samples
31
+ .filter((s) => s.properties.x != null && s.properties.y != null)
32
+ .map((s) => `${s.properties.x},${s.properties.y}`)
33
+ .join(" ");
34
+ // eslint-disable-next-line react-hooks/exhaustive-deps
35
+ }, [samples, trail, sampleCount, canvasRect.left, canvasRect.top]);
36
+
37
+ const simplifiedPath = useMemo(() => {
38
+ if (!simplifiedPoints || simplifiedPoints.size === 0) return "";
39
+ const pts: Array<{ x: number; y: number; pct: number }> = [];
40
+ for (const [pct, props] of simplifiedPoints) {
41
+ if (props.x != null && props.y != null) {
42
+ pts.push({ x: props.x, y: props.y, pct });
43
+ }
44
+ }
45
+ pts.sort((a, b) => a.pct - b.pct);
46
+ if (pts.length === 0) return "";
47
+ return pts.map((p) => `${p.x},${p.y}`).join(" ");
48
+ }, [simplifiedPoints]);
49
+
50
+ const diamondPositions = useMemo(() => {
51
+ if (!simplifiedPoints || simplifiedPoints.size === 0) return [];
52
+ const pts: Array<{ x: number; y: number; pct: number }> = [];
53
+ for (const [pct, props] of simplifiedPoints) {
54
+ if (props.x != null && props.y != null) {
55
+ pts.push({ x: props.x, y: props.y, pct });
56
+ }
57
+ }
58
+ return pts.sort((a, b) => a.pct - b.pct);
59
+ }, [simplifiedPoints]);
60
+
61
+ if (samples.length < 2 && !simplifiedPoints) return null;
62
+
63
+ return (
64
+ <svg
65
+ className="pointer-events-none fixed z-50"
66
+ style={{
67
+ left: canvasRect.left,
68
+ top: canvasRect.top,
69
+ width: canvasRect.width,
70
+ height: canvasRect.height,
71
+ }}
72
+ viewBox={
73
+ trail && trail.length > 1
74
+ ? `0 0 ${canvasRect.width} ${canvasRect.height}`
75
+ : `0 0 ${compositionSize?.width ?? canvasRect.width} ${compositionSize?.height ?? canvasRect.height}`
76
+ }
77
+ >
78
+ {mode === "recording" && trailPoints && (
79
+ <polyline
80
+ points={trailPoints}
81
+ fill="none"
82
+ stroke={accentColor}
83
+ strokeWidth="2"
84
+ strokeOpacity="0.6"
85
+ strokeLinecap="round"
86
+ strokeLinejoin="round"
87
+ />
88
+ )}
89
+
90
+ {mode === "preview" && (
91
+ <>
92
+ {trailPoints && (
93
+ <polyline
94
+ points={trailPoints}
95
+ fill="none"
96
+ stroke={accentColor}
97
+ strokeWidth="1"
98
+ strokeOpacity="0.2"
99
+ strokeDasharray="4 3"
100
+ strokeLinecap="round"
101
+ />
102
+ )}
103
+ {simplifiedPath && (
104
+ <polyline
105
+ points={simplifiedPath}
106
+ fill="none"
107
+ stroke={accentColor}
108
+ strokeWidth="2"
109
+ strokeOpacity="0.8"
110
+ strokeLinecap="round"
111
+ strokeLinejoin="round"
112
+ />
113
+ )}
114
+ {diamondPositions.map((pt) => (
115
+ <g key={pt.pct} transform={`translate(${pt.x}, ${pt.y})`}>
116
+ <rect
117
+ x="-4"
118
+ y="-4"
119
+ width="8"
120
+ height="8"
121
+ rx="1"
122
+ transform="rotate(45)"
123
+ fill={accentColor}
124
+ fillOpacity="0.9"
125
+ />
126
+ </g>
127
+ ))}
128
+ </>
129
+ )}
130
+ </svg>
131
+ );
132
+ });
@@ -1,5 +1,5 @@
1
1
  import { memo, useState } from "react";
2
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import type { ArcPathSegment, GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { Film } from "../../icons/SystemIcons";
4
4
  import { Section } from "./propertyPanelPrimitives";
5
5
  import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
@@ -23,6 +23,15 @@ interface GsapAnimationSectionProps {
23
23
  onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
24
24
  onLivePreview?: (property: string, value: number | string) => void;
25
25
  onLivePreviewEnd?: () => void;
26
+ onSetArcPath?: (
27
+ animationId: string,
28
+ config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
29
+ ) => void;
30
+ onUpdateArcSegment?: (
31
+ animationId: string,
32
+ segmentIndex: number,
33
+ update: Partial<ArcPathSegment>,
34
+ ) => void;
26
35
  }
27
36
 
28
37
  export const GsapAnimationSection = memo(function GsapAnimationSection({
@@ -40,6 +49,8 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
40
49
  onAddAnimation,
41
50
  onLivePreview,
42
51
  onLivePreviewEnd,
52
+ onSetArcPath,
53
+ onUpdateArcSegment,
43
54
  }: GsapAnimationSectionProps) {
44
55
  const [addMenuOpen, setAddMenuOpen] = useState(false);
45
56
 
@@ -75,6 +86,8 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
75
86
  onRemoveFromProperty={onRemoveFromProperty}
76
87
  onLivePreview={onLivePreview}
77
88
  onLivePreviewEnd={onLivePreviewEnd}
89
+ onSetArcPath={onSetArcPath}
90
+ onUpdateArcSegment={onUpdateArcSegment}
78
91
  />
79
92
  ))}
80
93
 
@@ -7,6 +7,7 @@ interface KeyframeDiamondProps {
7
7
  onClick: () => void;
8
8
  title?: string;
9
9
  size?: number;
10
+ isHold?: boolean;
10
11
  }
11
12
 
12
13
  // fallow-ignore-next-line complexity
@@ -15,10 +16,11 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
15
16
  onClick,
16
17
  title,
17
18
  size = 10,
19
+ isHold = false,
18
20
  }: KeyframeDiamondProps) {
19
21
  const isFilled = state === "active";
20
22
  const opacity = state === "ghost" ? 0.25 : state === "inactive" ? 0.6 : 1;
21
- const color = state === "active" ? "#3b82f6" : "#a3a3a3";
23
+ const color = state === "active" ? "#3CE6AC" : "#a3a3a3";
22
24
 
23
25
  return (
24
26
  <button
@@ -32,17 +34,30 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
32
34
  title={title}
33
35
  >
34
36
  <svg width={size} height={size} viewBox="0 0 10 10">
35
- <rect
36
- x="5"
37
- y="0.7"
38
- width="6"
39
- height="6"
40
- rx="1"
41
- transform="rotate(45 5 0.7)"
42
- fill={isFilled ? "currentColor" : "none"}
43
- stroke="currentColor"
44
- strokeWidth="1.2"
45
- />
37
+ {isHold ? (
38
+ <rect
39
+ x="2"
40
+ y="2"
41
+ width="6"
42
+ height="6"
43
+ rx="0.5"
44
+ fill={isFilled ? "currentColor" : "none"}
45
+ stroke="currentColor"
46
+ strokeWidth="1.2"
47
+ />
48
+ ) : (
49
+ <rect
50
+ x="5"
51
+ y="0.7"
52
+ width="6"
53
+ height="6"
54
+ rx="1"
55
+ transform="rotate(45 5 0.7)"
56
+ fill={isFilled ? "currentColor" : "none"}
57
+ stroke="currentColor"
58
+ strokeWidth="1.2"
59
+ />
60
+ )}
46
61
  </svg>
47
62
  </button>
48
63
  );