@hyperframes/studio 0.7.25 → 0.7.26

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 (34) hide show
  1. package/dist/assets/{index-CF35g_E4.js → index-BPya4QRs.js} +1 -1
  2. package/dist/assets/{index-HBY-OdAh.js → index-BZqvc9Ns.js} +183 -183
  3. package/dist/assets/{index-Bmd8RnCq.js → index-D3eh3pRK.js} +1 -1
  4. package/dist/index.d.ts +6 -0
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +377 -210
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/components/TimelineToolbar.test.tsx +50 -0
  10. package/src/components/TimelineToolbar.tsx +70 -35
  11. package/src/components/editor/MotionPathOverlay.tsx +27 -4
  12. package/src/components/editor/SnapToolbar.test.tsx +124 -0
  13. package/src/components/editor/SnapToolbar.tsx +1 -0
  14. package/src/hooks/gsapRuntimeBridge.test.ts +66 -0
  15. package/src/hooks/gsapRuntimeBridge.ts +41 -1
  16. package/src/hooks/gsapTweenSynth.test.ts +55 -0
  17. package/src/hooks/gsapTweenSynth.ts +12 -6
  18. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +162 -0
  19. package/src/hooks/gsapWholePropertyOffsetCommit.ts +85 -0
  20. package/src/hooks/useAnimatedPropertyCommit.test.tsx +105 -0
  21. package/src/hooks/useAnimatedPropertyCommit.ts +22 -0
  22. package/src/hooks/useAppHotkeys.ts +3 -2
  23. package/src/hooks/useGsapScriptCommits.test.tsx +8 -8
  24. package/src/hooks/useGsapScriptCommits.ts +6 -1
  25. package/src/hooks/useRenderClipContent.test.ts +54 -1
  26. package/src/hooks/useRenderClipContent.ts +7 -5
  27. package/src/player/components/Timeline.test.ts +51 -1
  28. package/src/player/components/Timeline.tsx +0 -2
  29. package/src/player/components/TimelineCanvas.tsx +1 -0
  30. package/src/player/components/TimelineClipDiamonds.test.tsx +215 -0
  31. package/src/player/components/TimelineClipDiamonds.tsx +46 -2
  32. package/src/player/store/playerStore.ts +8 -0
  33. package/src/utils/gsapSoftReload.test.ts +12 -0
  34. package/src/utils/gsapSoftReload.ts +15 -2
@@ -0,0 +1,215 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ });
13
+
14
+ function pointerEvent(type: string, init: PointerEventInit): Event {
15
+ if (typeof PointerEvent === "function") return new PointerEvent(type, init);
16
+ return new MouseEvent(type, init);
17
+ }
18
+
19
+ function renderDiamonds(onClickKeyframe = vi.fn()) {
20
+ const host = document.createElement("div");
21
+ document.body.append(host);
22
+ const root = createRoot(host);
23
+ act(() => {
24
+ root.render(
25
+ <TimelineClipDiamonds
26
+ keyframesData={{
27
+ format: "percentage",
28
+ keyframes: [
29
+ { percentage: 0, properties: { x: 0 } },
30
+ { percentage: 50, properties: { x: 100 } },
31
+ ],
32
+ }}
33
+ clipWidthPx={200}
34
+ clipHeightPx={48}
35
+ accentColor="#4ba3d2"
36
+ isSelected
37
+ currentPercentage={0}
38
+ elementId="clip-1"
39
+ selectedKeyframes={new Set()}
40
+ onClickKeyframe={onClickKeyframe}
41
+ />,
42
+ );
43
+ });
44
+ return { host, root, onClickKeyframe };
45
+ }
46
+
47
+ describe("TimelineClipDiamonds", () => {
48
+ it("treats primary pointerup without drag as a keyframe click", () => {
49
+ const { host, root, onClickKeyframe } = renderDiamonds();
50
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
51
+ expect(diamond).not.toBeNull();
52
+
53
+ act(() => {
54
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
55
+ });
56
+
57
+ expect(onClickKeyframe).toHaveBeenCalledWith(50);
58
+ act(() => root.unmount());
59
+ });
60
+
61
+ it("does not treat secondary pointerup as a keyframe click", () => {
62
+ const { host, root, onClickKeyframe } = renderDiamonds();
63
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
64
+ expect(diamond).not.toBeNull();
65
+
66
+ act(() => {
67
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 2 }));
68
+ });
69
+
70
+ expect(onClickKeyframe).not.toHaveBeenCalled();
71
+ act(() => root.unmount());
72
+ });
73
+
74
+ // Regression: once the clip is selected, canDrag arms on every diamond
75
+ // press. A real click's few px of mouse/trackpad jitter then resolves (via
76
+ // the neighbour clamp) back onto ~the same position — "noop", not "move" —
77
+ // which fell through neither branch and silently did nothing: no
78
+ // selection, no retime. It must still count as the click it was.
79
+ it("treats a drag-armed press that resolves to a no-op move as a click", () => {
80
+ const onClickKeyframe = vi.fn();
81
+ const onMoveKeyframe = vi.fn();
82
+ const host = document.createElement("div");
83
+ document.body.append(host);
84
+ const root = createRoot(host);
85
+ act(() => {
86
+ root.render(
87
+ <TimelineClipDiamonds
88
+ keyframesData={{
89
+ format: "percentage",
90
+ keyframes: [
91
+ { percentage: 0, properties: { x: 0 } },
92
+ { percentage: 50, properties: { x: 100 } },
93
+ ],
94
+ }}
95
+ clipWidthPx={5000}
96
+ clipHeightPx={48}
97
+ accentColor="#4ba3d2"
98
+ isSelected
99
+ currentPercentage={0}
100
+ elementId="clip-1"
101
+ selectedKeyframes={new Set()}
102
+ onClickKeyframe={onClickKeyframe}
103
+ onMoveKeyframe={onMoveKeyframe}
104
+ />,
105
+ );
106
+ });
107
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
108
+ expect(diamond).not.toBeNull();
109
+
110
+ act(() => {
111
+ diamond!.dispatchEvent(
112
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
113
+ );
114
+ // 4px of travel at a 5000px clip width is ~0.08 clip-% — above the drag
115
+ // threshold (so resolveKeyframeDrag doesn't short-circuit to "click"
116
+ // itself) but below the no-op epsilon once neighbour-clamped.
117
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
118
+ });
119
+
120
+ expect(onClickKeyframe).toHaveBeenCalledWith(50);
121
+ expect(onMoveKeyframe).not.toHaveBeenCalled();
122
+ act(() => root.unmount());
123
+ });
124
+
125
+ // Regression: a genuine retime (drag far enough to actually move the
126
+ // keyframe) committed the move but never selected/parked on the result —
127
+ // the diamond it was just dragged looked exactly like one nothing happened
128
+ // to. Select it at its NEW position too.
129
+ it("selects the keyframe at its new position after a real drag-retime", () => {
130
+ const onClickKeyframe = vi.fn();
131
+ const onMoveKeyframe = vi.fn();
132
+ const host = document.createElement("div");
133
+ document.body.append(host);
134
+ const root = createRoot(host);
135
+ act(() => {
136
+ root.render(
137
+ <TimelineClipDiamonds
138
+ keyframesData={{
139
+ format: "percentage",
140
+ keyframes: [
141
+ { percentage: 0, properties: { x: 0 } },
142
+ { percentage: 50, properties: { x: 100 } },
143
+ ],
144
+ }}
145
+ clipWidthPx={200}
146
+ clipHeightPx={48}
147
+ accentColor="#4ba3d2"
148
+ isSelected
149
+ currentPercentage={0}
150
+ elementId="clip-1"
151
+ selectedKeyframes={new Set()}
152
+ onClickKeyframe={onClickKeyframe}
153
+ onMoveKeyframe={onMoveKeyframe}
154
+ />,
155
+ );
156
+ });
157
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
158
+ expect(diamond).not.toBeNull();
159
+
160
+ act(() => {
161
+ diamond!.dispatchEvent(
162
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
163
+ );
164
+ // 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon,
165
+ // a real retime.
166
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
167
+ });
168
+
169
+ expect(onMoveKeyframe).toHaveBeenCalledWith("clip-1", 50, 52);
170
+ expect(onClickKeyframe).toHaveBeenCalledWith(52);
171
+ act(() => root.unmount());
172
+ });
173
+
174
+ // Regression: onClickKeyframe's state updates can re-render the diamond
175
+ // button out from under the gesture before the browser auto-synthesizes the
176
+ // "click" event that follows a button's pointerdown+pointerup. That orphaned
177
+ // click then bubbles to the ancestor clip's onClick, which toggles selection
178
+ // off whenever the clip is already selected — the state a diamond click
179
+ // always happens in — so every keyframe click immediately deselected its
180
+ // own clip. suppressClickRef lets that ancestor ignore the stray click.
181
+ it("arms suppressClickRef synchronously on a keyframe click", () => {
182
+ const suppressClickRef = { current: false };
183
+ const host = document.createElement("div");
184
+ document.body.append(host);
185
+ const root = createRoot(host);
186
+ act(() => {
187
+ root.render(
188
+ <TimelineClipDiamonds
189
+ keyframesData={{
190
+ format: "percentage",
191
+ keyframes: [{ percentage: 50, properties: { x: 100 } }],
192
+ }}
193
+ clipWidthPx={200}
194
+ clipHeightPx={48}
195
+ accentColor="#4ba3d2"
196
+ isSelected
197
+ currentPercentage={0}
198
+ elementId="clip-1"
199
+ selectedKeyframes={new Set()}
200
+ onClickKeyframe={vi.fn()}
201
+ suppressClickRef={suppressClickRef}
202
+ />,
203
+ );
204
+ });
205
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
206
+ expect(diamond).not.toBeNull();
207
+
208
+ act(() => {
209
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
210
+ });
211
+
212
+ expect(suppressClickRef.current).toBe(true);
213
+ act(() => root.unmount());
214
+ });
215
+ });
@@ -45,6 +45,10 @@ interface TimelineClipDiamondsProps {
45
45
  fromClipPercentage: number,
46
46
  toClipPercentage: number,
47
47
  ) => void;
48
+ /** Set while resolving a diamond press so the ancestor clip's onClick (which
49
+ * toggles selection off when already selected) ignores the native "click"
50
+ * the browser auto-synthesizes after this button's pointerdown+pointerup. */
51
+ suppressClickRef?: React.RefObject<boolean>;
48
52
  }
49
53
 
50
54
  const DIAMOND_RATIO = 0.8;
@@ -76,6 +80,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
76
80
  onShiftClickKeyframe,
77
81
  onContextMenuKeyframe,
78
82
  onMoveKeyframe,
83
+ suppressClickRef,
79
84
  }: TimelineClipDiamondsProps) {
80
85
  // Hooks must run before the early return below.
81
86
  const dragRef = useRef<DragState | null>(null);
@@ -83,6 +88,21 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
83
88
  // (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
84
89
  // on drop re-keys the diamond from source.
85
90
  const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
91
+ // The button element can re-render (reposition/unmount) synchronously from
92
+ // the state updates onClickKeyframe/onMoveKeyframe trigger, before the
93
+ // browser gets to auto-synthesize the "click" event that normally follows
94
+ // pointerdown+pointerup on a button. That orphaned click then fires on
95
+ // whatever ancestor is still there — the clip wrapper — whose own onClick
96
+ // toggles selection off when the clip is already selected (the state a
97
+ // diamond click always happens in). Suppressing it here is the same fix
98
+ // already used for clip drag/resize in useTimelineClipDrag.ts.
99
+ const suppressNextClick = () => {
100
+ if (!suppressClickRef) return;
101
+ suppressClickRef.current = true;
102
+ requestAnimationFrame(() => {
103
+ suppressClickRef.current = false;
104
+ });
105
+ };
86
106
 
87
107
  if (clipWidthPx < 20) return null;
88
108
 
@@ -102,7 +122,19 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
102
122
  const canDrag = isSelected && !!onMoveKeyframe;
103
123
 
104
124
  return (
105
- <div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
125
+ <div
126
+ className="absolute inset-0"
127
+ style={{
128
+ // Above the clip's trim-handle strips (TimelineClip.tsx, z-index 4) so
129
+ // a keyframe sitting in the first/last ~14px of the clip stays
130
+ // clickable instead of being covered by the resize handle. This div
131
+ // establishes its own stacking context (position + z-index), so the
132
+ // diamonds' own z-index (1/2) can't escape it on their own — the bump
133
+ // has to happen here.
134
+ zIndex: 5,
135
+ pointerEvents: "none",
136
+ }}
137
+ >
106
138
  {sorted.map((kf, i) => {
107
139
  if (i === 0) return null;
108
140
  const prev = sorted[i - 1]!;
@@ -178,6 +210,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
178
210
  const d = dragRef.current;
179
211
  // No drag armed (canDrag false / non-primary press) → treat as a click.
180
212
  if (!d || d.kfKey !== kfKey) {
213
+ if (e.button !== 0) return;
214
+ suppressNextClick();
181
215
  if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
182
216
  else onClickKeyframe?.(kf.percentage);
183
217
  return;
@@ -186,6 +220,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
186
220
  dragRef.current = null;
187
221
  setPreview(null);
188
222
  e.currentTarget.releasePointerCapture?.(e.pointerId);
223
+ suppressNextClick();
189
224
  const res = resolveKeyframeDrag({
190
225
  pointerDownX: d.startX,
191
226
  pointerUpX: e.clientX,
@@ -194,11 +229,20 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
194
229
  draggedIndex: i,
195
230
  sortedClipPcts,
196
231
  });
197
- if (res.kind === "click") {
232
+ if (res.kind === "click" || res.kind === "noop") {
233
+ // "noop" is a press with enough pointer jitter to arm a drag (canDrag
234
+ // is on for every diamond once the clip is selected) that resolved
235
+ // back onto ~the same position — no real retime, so treat it as the
236
+ // click it was. Otherwise a normal click with a few px of mouse/
237
+ // trackpad drift silently does nothing: no selection, no move.
198
238
  if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
199
239
  else onClickKeyframe?.(kf.percentage);
200
240
  } else if (res.kind === "move" && res.toClipPct != null) {
201
241
  onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
242
+ // A retime still targeted this exact diamond — park/select it at its
243
+ // new position, same as a plain click, or a drag that actually moved
244
+ // something looks identical to one that silently did nothing.
245
+ onClickKeyframe?.(res.toClipPct);
202
246
  }
203
247
  };
204
248
 
@@ -104,6 +104,12 @@ interface PlayerState {
104
104
  setMotionPathArmed: (armed: boolean) => void;
105
105
  motionPathCreateAvailable: boolean;
106
106
  setMotionPathCreateAvailable: (available: boolean) => void;
107
+ /** Global toggle for the "Add keyframe" diamond in the timeline toolbar (#1808).
108
+ * When false, a manual drag/resize/rotate edit on an element that already has
109
+ * a live tween shifts every keyframe by the edit's delta (preserving the
110
+ * animation's shape) instead of inserting/updating a keyframe at the playhead. */
111
+ autoKeyframeEnabled: boolean;
112
+ setAutoKeyframeEnabled: (enabled: boolean) => void;
107
113
 
108
114
  /** Multi-select: additional selected elements beyond selectedElementId. */
109
115
  selectedElementIds: Set<string>;
@@ -238,6 +244,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
238
244
  setMotionPathArmed: (armed) => set({ motionPathArmed: armed }),
239
245
  motionPathCreateAvailable: false,
240
246
  setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
247
+ autoKeyframeEnabled: true,
248
+ setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
241
249
 
242
250
  selectedElementIds: new Set<string>(),
243
251
  toggleSelectedElementId: (id: string) =>
@@ -100,6 +100,18 @@ describe("applySoftReload", () => {
100
100
  expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
101
101
  });
102
102
 
103
+ it("seeks to the caller-supplied currentTime override instead of the iframe's own __player.getTime()", () => {
104
+ // Regression: the iframe's raw __player.getTime() (2.0 here, per the mock)
105
+ // can desync from the studio's authoritative scrub position — e.g. a
106
+ // keyframe-node drag parks the playhead via the store before this reload's
107
+ // async commit resolves. The rebuilt timeline must re-seek to the caller's
108
+ // value, not the iframe's possibly-stale one.
109
+ const { iframe, contentWindow } = buildMockIframe();
110
+ const result = applySoftReload(iframe, SCRIPT_TEXT, undefined, 0);
111
+ expect(result).toBe("applied");
112
+ expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
113
+ });
114
+
103
115
  it("strips a stale inline transform from an orphaned (non-timeline-child) element", () => {
104
116
  // Repro: an element dragged via gsap.set whose keyframes were then removed is
105
117
  // no longer a timeline child, so the timeline-children sweep misses it. Its
@@ -175,6 +175,7 @@ export function applySoftReload(
175
175
  iframe: HTMLIFrameElement | null,
176
176
  scriptText: string,
177
177
  onAsyncFailure?: () => void,
178
+ currentTimeOverride?: number,
178
179
  ): SoftReloadResult {
179
180
  if (!iframe || !scriptText) return "cannot-soft-reload";
180
181
 
@@ -210,7 +211,14 @@ export function applySoftReload(
210
211
  // rather than killing the target timeline and appending an orphan script.
211
212
  if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload";
212
213
 
213
- const currentTime = win.__player?.getTime?.() ?? 0;
214
+ // Prefer the caller-supplied scrub position (the studio's own authoritative
215
+ // currentTime, e.g. usePlayerStore) over the iframe's raw `__player.getTime()`:
216
+ // the two can desync (a keyframe-node drag parks the playhead via the store
217
+ // BEFORE this reload's async commit resolves, and the iframe's own GSAP clock
218
+ // doesn't reliably reflect that yet), which re-seeks the freshly rebuilt
219
+ // timeline to the wrong frame and leaves the element (and its overlay)
220
+ // rendered at a stale/unrelated position.
221
+ const currentTime = currentTimeOverride ?? win.__player?.getTime?.() ?? 0;
214
222
 
215
223
  // Track whether the MotionPath async path was taken. When it is, the script
216
224
  // executes inside pluginScript.onload — after applySoftReload has already
@@ -300,8 +308,13 @@ export function applySoftReload(
300
308
  const s = doc.createElement("script");
301
309
  s.textContent = `(function(){${scriptText}\n})();`;
302
310
  doc.body.appendChild(s);
303
- win.__hfForceTimelineRebind?.();
311
+ // Seek BEFORE rebind: __hfForceTimelineRebind's own internal force-render
312
+ // (see init.ts) renders the freshly-created timeline at whatever the
313
+ // runtime's internal scrub position already is, not at whatever we pass
314
+ // here afterward — a redundant seek() call after rebind can be a GSAP
315
+ // no-op if the timeline already reports being at that time internally.
304
316
  win.__player?.seek?.(currentTime);
317
+ win.__hfForceTimelineRebind?.();
305
318
  win.__hfStudioManualEditsApply?.();
306
319
  };
307
320