@hyperframes/studio 0.7.18 → 0.7.20

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 (39) hide show
  1. package/dist/assets/hyperframes-player-DNLS_l47.js +459 -0
  2. package/dist/assets/{index-mZiDOLTB.js → index-CUt5jJXy.js} +1 -1
  3. package/dist/assets/{index-B7_M3NXS.js → index-D-KLiCTU.js} +1 -1
  4. package/dist/assets/index-WxXW8fW3.js +396 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1246 -807
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -7
  10. package/src/components/StudioPreviewArea.tsx +92 -17
  11. package/src/components/TimelineToolbar.tsx +8 -0
  12. package/src/components/editor/MotionPathOverlay.tsx +4 -2
  13. package/src/components/editor/keyframeDrag.test.ts +195 -0
  14. package/src/components/editor/keyframeDrag.ts +105 -0
  15. package/src/components/editor/keyframeRetime.test.ts +140 -0
  16. package/src/components/editor/keyframeRetime.ts +121 -0
  17. package/src/contexts/DomEditContext.tsx +12 -0
  18. package/src/contexts/TimelineEditContext.tsx +2 -0
  19. package/src/hooks/gsapDragCommit.ts +28 -1
  20. package/src/hooks/gsapPositionDetection.ts +98 -0
  21. package/src/hooks/gsapRuntimeBridge.test.ts +33 -0
  22. package/src/hooks/gsapRuntimeBridge.ts +41 -97
  23. package/src/hooks/useDomEditSession.ts +10 -0
  24. package/src/hooks/useDomEditWiring.ts +17 -0
  25. package/src/hooks/useGsapKeyframeOps.test.tsx +101 -0
  26. package/src/hooks/useGsapKeyframeOps.ts +63 -2
  27. package/src/hooks/useGsapSelectionHandlers.ts +72 -1
  28. package/src/hooks/useGsapTweenCache.ts +16 -7
  29. package/src/hooks/useKeyframeKeyboard.ts +29 -32
  30. package/src/player/components/KeyframeDiamondContextMenu.tsx +21 -2
  31. package/src/player/components/PlayerControls.tsx +40 -13
  32. package/src/player/components/Timeline.tsx +8 -0
  33. package/src/player/components/TimelineCanvas.tsx +7 -0
  34. package/src/player/components/TimelineClipDiamonds.tsx +109 -17
  35. package/src/player/components/timelineCallbacks.ts +6 -0
  36. package/src/utils/gsapSoftReload.test.ts +29 -0
  37. package/src/utils/gsapSoftReload.ts +19 -0
  38. package/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
  39. package/dist/assets/index-D0468l1X.js +0 -375
@@ -1,8 +1,15 @@
1
- import { memo } from "react";
1
+ import { memo, useRef, useState } from "react";
2
2
  import { BEAT_BAND_H } from "./BeatStrip";
3
+ import {
4
+ KEYFRAME_DRAG_THRESHOLD_PX,
5
+ previewClipPct,
6
+ resolveKeyframeDrag,
7
+ } from "../../components/editor/keyframeDrag";
3
8
 
4
9
  interface KeyframeEntry {
5
10
  percentage: number;
11
+ /** Tween-relative percentage (the retime mutation keys on this, not clip %). */
12
+ tweenPercentage?: number;
6
13
  properties: Record<string, number | string>;
7
14
  ease?: string;
8
15
  }
@@ -29,6 +36,15 @@ interface TimelineClipDiamondsProps {
29
36
  onClickKeyframe?: (percentage: number) => void;
30
37
  onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
31
38
  onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
39
+ /** Drag-to-retime: move a keyframe to a new time, preserving its value + ease.
40
+ * Both percentages are clip-relative: `fromClipPercentage` identifies the
41
+ * dragged keyframe, `toClipPercentage` is the neighbour-clamped drop position.
42
+ * The handler decides move (within the tween) vs resize (past its boundary). */
43
+ onMoveKeyframe?: (
44
+ elementId: string,
45
+ fromClipPercentage: number,
46
+ toClipPercentage: number,
47
+ ) => void;
32
48
  }
33
49
 
34
50
  const DIAMOND_RATIO = 0.8;
@@ -39,6 +55,13 @@ const DIAMOND_RATIO = 0.8;
39
55
  const KF_MIN_PCT = -5;
40
56
  const KF_MAX_PCT = 105;
41
57
 
58
+ type DragState = {
59
+ kfKey: string;
60
+ startX: number;
61
+ fromClipPct: number;
62
+ moved: boolean;
63
+ };
64
+
42
65
  export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
43
66
  keyframesData,
44
67
  clipWidthPx,
@@ -52,7 +75,15 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
52
75
  onClickKeyframe,
53
76
  onShiftClickKeyframe,
54
77
  onContextMenuKeyframe,
78
+ onMoveKeyframe,
55
79
  }: TimelineClipDiamondsProps) {
80
+ // Hooks must run before the early return below.
81
+ const dragRef = useRef<DragState | null>(null);
82
+ // Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
83
+ // (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
84
+ // on drop re-keys the diamond from source.
85
+ const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
86
+
56
87
  if (clipWidthPx < 20) return null;
57
88
 
58
89
  // When the beat strip occupies the top band, shrink the diamonds and center
@@ -63,17 +94,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
63
94
  const sorted = keyframesData.keyframes
64
95
  .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
65
96
  .sort((a, b) => a.percentage - b.percentage);
97
+ // Clip-%s of the sorted keyframes — the neighbour clamp (preview + drop) needs
98
+ // the whole row to bound the dragged diamond between its immediate siblings.
99
+ const sortedClipPcts = sorted.map((k) => k.percentage);
66
100
  const baseColor = isSelected ? accentColor : "#a3a3a3";
67
101
  const baseOpacity = isSelected ? 0.4 : 0.25;
68
-
69
- const handleClick = (e: React.MouseEvent, pct: number) => {
70
- e.stopPropagation();
71
- if (e.shiftKey) {
72
- onShiftClickKeyframe?.(elementId, pct);
73
- } else {
74
- onClickKeyframe?.(pct);
75
- }
76
- };
102
+ const canDrag = isSelected && !!onMoveKeyframe;
77
103
 
78
104
  return (
79
105
  <div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
@@ -102,17 +128,80 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
102
128
  })}
103
129
 
104
130
  {sorted.map((kf, i) => {
131
+ const kfKey = `${elementId}:${kf.percentage}`;
132
+ // While dragging this diamond, render it at the live preview clip-%.
133
+ const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage;
105
134
  // Center the diamond ON its keyframe %: left = (% · width) − half so the
106
135
  // diamond's midpoint sits exactly at the percentage. At 0% the midpoint
107
- // is the clip's left edge (the diamond's left half overflows, which the
108
- // overflow-visible clip shows) — NOT shifted fully inside. No clamp, or
109
- // boundary keyframes (0% / 100%) would render off-center.
110
- const leftPx = (kf.percentage / 100) * clipWidthPx - half;
111
- const kfKey = `${elementId}:${kf.percentage}`;
136
+ // is the clip's left edge (the left half overflows, which the
137
+ // overflow-visible clip shows) — NOT shifted fully inside.
138
+ const leftPx = (renderPct / 100) * clipWidthPx - half;
112
139
  const isKfSelected = selectedKeyframes.has(kfKey);
113
140
  const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
114
141
  const isHighlighted = isKfSelected || atPlayhead;
115
142
  const color = isHighlighted ? accentColor : "#a3a3a3";
143
+
144
+ const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
145
+ if (e.button !== 0) return;
146
+ e.stopPropagation();
147
+ if (canDrag) {
148
+ e.currentTarget.setPointerCapture?.(e.pointerId);
149
+ dragRef.current = {
150
+ kfKey,
151
+ startX: e.clientX,
152
+ fromClipPct: kf.percentage,
153
+ moved: false,
154
+ };
155
+ }
156
+ };
157
+ const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
158
+ const d = dragRef.current;
159
+ if (!d || d.kfKey !== kfKey) return;
160
+ if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
161
+ d.moved = true;
162
+ }
163
+ if (d.moved) {
164
+ setPreview({
165
+ kfKey,
166
+ clipPct: previewClipPct({
167
+ pointerDownX: d.startX,
168
+ pointerMoveX: e.clientX,
169
+ clipWidthPx,
170
+ draggedClipPct: d.fromClipPct,
171
+ draggedIndex: i,
172
+ sortedClipPcts,
173
+ }),
174
+ });
175
+ }
176
+ };
177
+ const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
178
+ const d = dragRef.current;
179
+ // No drag armed (canDrag false / non-primary press) → treat as a click.
180
+ if (!d || d.kfKey !== kfKey) {
181
+ if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
182
+ else onClickKeyframe?.(kf.percentage);
183
+ return;
184
+ }
185
+ e.stopPropagation();
186
+ dragRef.current = null;
187
+ setPreview(null);
188
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
189
+ const res = resolveKeyframeDrag({
190
+ pointerDownX: d.startX,
191
+ pointerUpX: e.clientX,
192
+ clipWidthPx,
193
+ draggedClipPct: d.fromClipPct,
194
+ draggedIndex: i,
195
+ sortedClipPcts,
196
+ });
197
+ if (res.kind === "click") {
198
+ if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
199
+ else onClickKeyframe?.(kf.percentage);
200
+ } else if (res.kind === "move" && res.toClipPct != null) {
201
+ onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
202
+ }
203
+ };
204
+
116
205
  return (
117
206
  <button
118
207
  key={`${i}-${kf.percentage}`}
@@ -128,10 +217,13 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
128
217
  pointerEvents: "auto",
129
218
  background: "none",
130
219
  border: "none",
131
- cursor: "pointer",
220
+ cursor: canDrag ? "ew-resize" : "pointer",
132
221
  padding: 0,
222
+ touchAction: "none",
133
223
  }}
134
- onClick={(e) => handleClick(e, kf.percentage)}
224
+ onPointerDown={onPointerDown}
225
+ onPointerMove={onPointerMove}
226
+ onPointerUp={onPointerUp}
135
227
  onContextMenu={(e) => {
136
228
  e.preventDefault();
137
229
  e.stopPropagation();
@@ -39,5 +39,11 @@ export interface TimelineEditCallbacks {
39
39
  onDeleteKeyframe?: (elementId: string, percentage: number) => void;
40
40
  onDeleteAllKeyframes?: (elementId: string) => void;
41
41
  onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
42
+ onMoveKeyframeToPlayhead?: (elementId: string, percentage: number) => void;
43
+ onMoveKeyframe?: (
44
+ elementId: string,
45
+ fromClipPercentage: number,
46
+ toClipPercentage: number,
47
+ ) => void;
42
48
  onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
43
49
  }
@@ -100,6 +100,35 @@ describe("applySoftReload", () => {
100
100
  expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
101
101
  });
102
102
 
103
+ it("strips a stale inline transform from an orphaned (non-timeline-child) element", () => {
104
+ // Repro: an element dragged via gsap.set whose keyframes were then removed is
105
+ // no longer a timeline child, so the timeline-children sweep misses it. Its
106
+ // stale inline transform must still be cleared so it snaps back to its source
107
+ // (overlay) position instead of rendering offset.
108
+ const orphan = document.createElement("div");
109
+ orphan.style.cssText = "left: 1240px; top: 200px; transform: translate(449px, 0px)";
110
+ Object.assign(orphan, { _gsap: {} }); // GSAP cache marker (set by gsap.set)
111
+
112
+ const scriptEl = document.createElement("script");
113
+ scriptEl.textContent = 'const tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 });';
114
+ const container = document.createElement("div");
115
+ container.appendChild(scriptEl);
116
+
117
+ const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
118
+ (iframe as unknown as { contentDocument: unknown }).contentDocument = {
119
+ querySelectorAll: (sel: string) =>
120
+ sel === "script:not([src])" ? [scriptEl] : sel === "[style*='transform']" ? [orphan] : [],
121
+ createElement: (tag: string) => document.createElement(tag),
122
+ body: container,
123
+ head: document.createElement("div"),
124
+ };
125
+
126
+ applySoftReload(iframe, SCRIPT_TEXT);
127
+
128
+ expect(orphan.style.transform).toBe(""); // stale GSAP transform stripped
129
+ expect(orphan.style.left).toBe("1240px"); // authored CSS base preserved
130
+ });
131
+
103
132
  it("wraps execution in __hfSuppressSceneMutations when available", () => {
104
133
  let suppressionCalled = false;
105
134
  const { iframe } = buildMockIframe({
@@ -251,6 +251,25 @@ export function applySoftReload(
251
251
  }
252
252
  }
253
253
 
254
+ // Also reset elements carrying a GSAP-applied inline `transform` that the
255
+ // timeline-children sweep above missed — a dragged element whose position
256
+ // was a standalone `gsap.set` (never a timeline child), or one whose
257
+ // keyframes were just removed (no longer in any timeline). Their last
258
+ // `gsap.set` transform is otherwise orphaned: the re-run won't re-set it
259
+ // and the sweep above can't see it, so the element renders offset from its
260
+ // source position (matching the overlay) until a full reload. The clear
261
+ // below runs BEFORE the re-run, which re-applies the transform for any
262
+ // element the new script still animates.
263
+ const seenTargets = new Set<Element>(allTargets);
264
+ for (const el of doc.querySelectorAll<HTMLElement>("[style*='transform']")) {
265
+ // Gate on the GSAP cache (`_gsap`) so we only reset transforms GSAP owns —
266
+ // never strip an authored, non-GSAP inline transform.
267
+ if (el.style.transform && "_gsap" in el && !seenTargets.has(el)) {
268
+ seenTargets.add(el);
269
+ allTargets.push(el);
270
+ }
271
+ }
272
+
254
273
  // Reset GSAP's internal transform cache so from() tweens don't read stale
255
274
  // end values. `clearProps: "all"` is needed to flush the cache, but it also
256
275
  // nukes the element's CSS base (position, width, height, etc.) from the