@hyperframes/studio 0.7.89 → 0.7.92

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 (86) hide show
  1. package/dist/assets/{hyperframes-player-DVTnBvSO.js → hyperframes-player-9qOa08Tv.js} +1 -1
  2. package/dist/assets/{index-Qvx7boWH.js → index-Bw4lNdtO.js} +1 -1
  3. package/dist/assets/{index-ulQEdWKo.js → index-CDTN2ZVa.js} +1 -1
  4. package/dist/assets/index-DIntkrQl.js +428 -0
  5. package/dist/index.d.ts +20 -16
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +6495 -5429
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioRightPanel.tsx +1 -1
  11. package/src/components/editor/GsapAnimationList.tsx +84 -0
  12. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  13. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  14. package/src/components/editor/PropertyPanel.tsx +1 -1
  15. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  16. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  17. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  18. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  19. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  20. package/src/components/editor/propertyPanelTypes.ts +1 -1
  21. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  22. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  23. package/src/components/renders/useRenderQueue.ts +16 -3
  24. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  25. package/src/contexts/DomEditContext.tsx +1 -1
  26. package/src/hooks/useDomEditSession.test.tsx +1 -1
  27. package/src/hooks/useDomEditSession.ts +1 -1
  28. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  29. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  30. package/src/player/components/BeatStrip.test.tsx +374 -0
  31. package/src/player/components/BeatStrip.tsx +295 -37
  32. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  33. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  34. package/src/player/components/Timeline.test.ts +2 -3
  35. package/src/player/components/Timeline.tsx +47 -48
  36. package/src/player/components/TimelineCanvas.tsx +14 -71
  37. package/src/player/components/TimelineClip.tsx +6 -3
  38. package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
  39. package/src/player/components/TimelineClipDiamonds.tsx +66 -211
  40. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  41. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  42. package/src/player/components/TimelineLanes.test.tsx +0 -1
  43. package/src/player/components/TimelineLanes.tsx +3 -2
  44. package/src/player/components/TimelineOverlays.test.ts +161 -0
  45. package/src/player/components/TimelineOverlays.tsx +104 -11
  46. package/src/player/components/timelineCallbacks.ts +1 -1
  47. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  48. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  49. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  50. package/src/player/components/timelineClipDragPreview.ts +18 -12
  51. package/src/player/components/timelineClipDragTypes.ts +12 -0
  52. package/src/player/components/timelineDiamondTypes.ts +5 -15
  53. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  54. package/src/player/components/timelineDragDrop.ts +152 -30
  55. package/src/player/components/timelineLaneProps.ts +0 -1
  56. package/src/player/components/timelineMarquee.test.ts +73 -25
  57. package/src/player/components/timelineMarquee.ts +44 -16
  58. package/src/player/components/timelineTestViewport.ts +13 -0
  59. package/src/player/components/timelineViewModel.ts +4 -4
  60. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  61. package/src/player/components/useTimelineClipDrag.ts +112 -70
  62. package/src/player/components/useTimelineClipRenderWindow.ts +4 -4
  63. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  64. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  65. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  66. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  67. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  68. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  69. package/src/player/components/useTimelineRowVirtualization.ts +11 -4
  70. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  71. package/src/player/components/useTrackGapMenu.ts +32 -9
  72. package/src/player/store/keyframeSlice.ts +59 -18
  73. package/src/player/store/playerStore.test.ts +76 -0
  74. package/src/player/store/playerStore.ts +19 -11
  75. package/src/telemetry/canary.test.ts +325 -0
  76. package/src/telemetry/canary.ts +268 -0
  77. package/src/telemetry/client.test.ts +8 -3
  78. package/src/telemetry/client.ts +13 -43
  79. package/src/telemetry/config.ts +16 -2
  80. package/src/telemetry/distinctId.test.ts +35 -0
  81. package/src/telemetry/distinctId.ts +11 -2
  82. package/src/telemetry/policy.test.ts +81 -0
  83. package/src/telemetry/policy.ts +104 -0
  84. package/src/utils/studioTelemetry.test.ts +76 -0
  85. package/src/utils/studioTelemetry.ts +14 -6
  86. package/dist/assets/index-CddKwfQ1.js +0 -428
@@ -1,4 +1,4 @@
1
- import { useRef, useState, useCallback, useMemo } from "react";
1
+ import { useRef, useState, useCallback, useMemo, useEffect } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import {
4
4
  applyTimelineAutoScrollStep,
@@ -25,9 +25,13 @@ import type {
25
25
  ResizingClipState,
26
26
  BlockedClipState,
27
27
  } from "./timelineClipDragTypes";
28
- import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle";
29
28
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
30
29
  import type { TimelineRowGeometry } from "./timelineLayout";
30
+ import {
31
+ mountTimelineClipDragGestureLifecycle,
32
+ type TimelineGestureKind,
33
+ type TimelineGestureLifecycle,
34
+ } from "./timelineClipDragGestureLifecycle";
31
35
 
32
36
  export type {
33
37
  DraggedClipState,
@@ -74,6 +78,7 @@ interface UseTimelineClipDragInput {
74
78
  readZIndex?: (element: TimelineElement) => number;
75
79
  onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
76
80
  refreshAfterLaneMove?: () => void;
81
+ sessionEpoch?: number;
77
82
  }
78
83
 
79
84
  export function useTimelineClipDrag({
@@ -92,6 +97,7 @@ export function useTimelineClipDrag({
92
97
  readZIndex,
93
98
  onStackingPatches,
94
99
  refreshAfterLaneMove,
100
+ sessionEpoch = 0,
95
101
  }: UseTimelineClipDragInput) {
96
102
  const updateElement = usePlayerStore((s) => s.updateElement);
97
103
  const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
@@ -162,41 +168,72 @@ export function useTimelineClipDrag({
162
168
  [],
163
169
  );
164
170
 
165
- const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
171
+ const [draggedClip, setDraggedClipState] = useState<DraggedClipState | null>(null);
166
172
  const draggedClipRef = useRef<DraggedClipState | null>(null);
167
- draggedClipRef.current = draggedClip;
173
+ const publishDraggedClip = useCallback((next: DraggedClipState | null) => {
174
+ draggedClipRef.current = next;
175
+ setDraggedClipState(next);
176
+ }, []);
168
177
 
169
- const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
178
+ const [resizingClip, setResizingClipState] = useState<ResizingClipState | null>(null);
170
179
  const resizingClipRef = useRef<ResizingClipState | null>(null);
171
- resizingClipRef.current = resizingClip;
180
+ const publishResizingClip = useCallback((next: ResizingClipState | null) => {
181
+ resizingClipRef.current = next;
182
+ setResizingClipState(next);
183
+ }, []);
184
+
185
+ const lifecycleRef = useRef<TimelineGestureLifecycle>({
186
+ kind: null,
187
+ phase: "complete",
188
+ pointerId: null,
189
+ sessionEpoch,
190
+ });
191
+ const sessionEpochRef = useRef(sessionEpoch);
192
+ sessionEpochRef.current = sessionEpoch;
193
+ const gestureSelectedKeysRef = useRef<ReadonlySet<string>>(new Set());
194
+ const cancelGestureRef = useRef<
195
+ (options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
196
+ >(() => false);
197
+ const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number) => {
198
+ if (lifecycleRef.current.phase === "active") cancelGestureRef.current();
199
+ lifecycleRef.current = {
200
+ kind,
201
+ phase: "active",
202
+ pointerId,
203
+ sessionEpoch: sessionEpochRef.current,
204
+ };
205
+ gestureSelectedKeysRef.current = new Set(usePlayerStore.getState().selectedElementIds);
206
+ }, []);
207
+ const setDraggedClip = useCallback(
208
+ (next: DraggedClipState | null) => {
209
+ if (!next) {
210
+ cancelGestureRef.current();
211
+ return;
212
+ }
213
+ beginGesture("drag", next.pointerId);
214
+ publishDraggedClip(next);
215
+ },
216
+ [beginGesture, publishDraggedClip],
217
+ );
218
+ const setResizingClip = useCallback(
219
+ (next: ResizingClipState | null) => {
220
+ if (!next) {
221
+ cancelGestureRef.current();
222
+ return;
223
+ }
224
+ beginGesture("resize", next.pointerId);
225
+ publishResizingClip(next);
226
+ },
227
+ [beginGesture, publishResizingClip],
228
+ );
172
229
 
173
230
  const blockedClipRef = useRef<BlockedClipState | null>(null);
174
231
  const suppressClickRef = useRef(false);
175
232
 
176
- // Active multi-select group-resize session (restored from main 36413da7f): set
177
- // lazily on the first resize pointermove when the grabbed clip is part of a
178
- // capability-clean multi-selection (null ⇒ single-clip resize). Holds the
179
- // pre-gesture snapshot so the non-grabbed members (previewed through the store)
180
- // roll back on escape / cancel / failed persist.
233
+ // Active multi-select group-resize session, created lazily on first movement.
234
+ // It owns a projection only; canonical store timing changes at commit.
181
235
  const groupResizeRef = useRef<TimelineGroupResizeSession | null>(null);
182
236
 
183
- // Restore the non-grabbed group members to their pre-gesture timing (the
184
- // grabbed clip renders from resizingClip state, so it is never written during
185
- // preview). `all` also restores the grabbed clip after a committed persist fails.
186
- const restoreGroupResizeMembers = useCallback(
187
- (session: TimelineGroupResizeSession, all = false) => {
188
- for (const m of session.members) {
189
- if (!all && m.key === session.grabbedKey) continue;
190
- updateElement(m.key, {
191
- start: m.start,
192
- duration: m.duration,
193
- playbackStart: m.playbackStart,
194
- });
195
- }
196
- },
197
- [updateElement],
198
- );
199
-
200
237
  const onMoveElementRef = useRef(onMoveElement);
201
238
  onMoveElementRef.current = onMoveElement;
202
239
  const onMoveElementsRef = useRef(onMoveElements);
@@ -237,7 +274,7 @@ export function useTimelineClipDrag({
237
274
  trackOrder: trackOrderRef.current,
238
275
  rowHeights: rowGeometryRef?.current.rowHeights,
239
276
  elements: elementsRef.current,
240
- selectedKeys: usePlayerStore.getState().selectedElementIds,
277
+ selectedKeys: gestureSelectedKeysRef.current,
241
278
  buildSnapTargets,
242
279
  audioTracks: dragAudioTracksRef.current,
243
280
  });
@@ -256,18 +293,19 @@ export function useTimelineClipDrag({
256
293
  buildSnapTargets,
257
294
  });
258
295
  const setResizeState = (v: ResizePreviewResult) =>
259
- setResizingClip((prev) => (prev ? { ...prev, started: true, ...v } : prev));
296
+ publishResizingClip(
297
+ resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
298
+ );
260
299
 
261
300
  // Group resize: a capability-clean multi-selection resizes rigidly by one
262
301
  // shared, member-clamped delta (legacy main 36413da7f). The grabbed clip
263
- // drives the raw delta and renders from resizingClip state; non-grabbed
264
- // members preview through the store (their store value stays pristine).
302
+ // drives the raw delta; every member renders from the coordinator projection.
265
303
  const grabbedKey = resize.element.key ?? resize.element.id;
266
304
  let session = groupResizeRef.current;
267
305
  if (!session || session.grabbedKey !== grabbedKey || session.edge !== resize.edge) {
268
306
  const members = buildTimelineGroupResizeMembers(
269
307
  elementsRef.current,
270
- usePlayerStore.getState().selectedElementIds,
308
+ gestureSelectedKeysRef.current,
271
309
  grabbedKey,
272
310
  resize.edge,
273
311
  );
@@ -287,9 +325,9 @@ export function useTimelineClipDrag({
287
325
  setResizeState(next);
288
326
  return;
289
327
  }
290
- previewGroupResize(session, next, grabbedKey, updateElement, setResizeState);
328
+ previewGroupResize(session, next, setResizeState);
291
329
  },
292
- [scrollRef, ppsRef, buildSnapTargets, updateElement],
330
+ [scrollRef, ppsRef, buildSnapTargets, publishResizingClip],
293
331
  );
294
332
  const applyResizePointerRef = useRef(applyResizePointer);
295
333
  applyResizePointerRef.current = applyResizePointer;
@@ -300,9 +338,7 @@ export function useTimelineClipDrag({
300
338
  cancelAnimationFrame(clipDragScrollRaf.current);
301
339
  clipDragScrollRaf.current = 0;
302
340
  }
303
- // Gesture teardown: drop the frozen-per-gesture perf caches so the next drag
304
- // rebuilds them against fresh store state (see snapTargetsCacheRef). Does NOT
305
- // touch groupResizeRef — commit reads it after this runs.
341
+ // Gesture teardown: drop frozen caches so the next gesture reads fresh state.
306
342
  snapTargetsCacheRef.current.clear();
307
343
  dragAudioTracksRef.current = null;
308
344
  }, []);
@@ -317,16 +353,14 @@ export function useTimelineClipDrag({
317
353
  if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
318
354
 
319
355
  if (drag) {
320
- setDraggedClip((prev) =>
321
- prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
322
- );
356
+ publishDraggedClip(updateDraggedClipPreview(drag, pointer.clientX, pointer.clientY));
323
357
  } else if (resize) {
324
358
  // Re-run the trim preview so the edge keeps tracking while the content
325
359
  // scrolls under the stationary pointer (scroll-compensated pointer x).
326
360
  applyResizePointerRef.current(resize, pointer.clientX);
327
361
  }
328
362
  clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
329
- }, [scrollRef, updateDraggedClipPreview]);
363
+ }, [publishDraggedClip, scrollRef, updateDraggedClipPreview]);
330
364
 
331
365
  const syncClipDragAutoScroll = useCallback(
332
366
  (clientX: number, clientY: number) => {
@@ -354,35 +388,44 @@ export function useTimelineClipDrag({
354
388
  const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
355
389
  stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
356
390
 
357
- useMountEffect(() => {
358
- return mountTimelineClipDragGestureLifecycle({
359
- draggedClipRef,
360
- resizingClipRef,
361
- blockedClipRef,
362
- groupResizeRef,
363
- suppressClickRef,
364
- elementsRef,
365
- trackOrderRef,
366
- setDraggedClip,
367
- setResizingClip,
368
- setShowPopover,
369
- setRangeSelectionRef,
370
- applyResizePointerRef,
371
- syncClipDragAutoScrollRef,
372
- stopClipDragAutoScrollRef,
373
- updateDraggedClipPreviewRef,
374
- restoreGroupResizeMembers,
375
- updateElement,
376
- onMoveElementRef,
377
- onMoveElementsRef,
378
- onResizeElementRef,
379
- onResizeElementsRef,
380
- onBlockedEditAttemptRef,
381
- readZIndexRef,
391
+ useMountEffect(() =>
392
+ mountTimelineClipDragGestureLifecycle({
382
393
  onStackingPatchesRef,
383
394
  refreshAfterLaneMoveRef,
384
- });
385
- });
395
+ readZIndexRef,
396
+ onBlockedEditAttemptRef,
397
+ onResizeElementsRef,
398
+ onResizeElementRef,
399
+ onMoveElementsRef,
400
+ onMoveElementRef,
401
+ updateElement,
402
+ publishDraggedClip,
403
+ updateDraggedClipPreviewRef,
404
+ stopClipDragAutoScrollRef,
405
+ syncClipDragAutoScrollRef,
406
+ applyResizePointerRef,
407
+ setRangeSelectionRef,
408
+ setShowPopover,
409
+ setResizingClipState,
410
+ setDraggedClipState,
411
+ trackOrderRef,
412
+ elementsRef,
413
+ gestureSelectedKeysRef,
414
+ suppressClickRef,
415
+ groupResizeRef,
416
+ blockedClipRef,
417
+ resizingClipRef,
418
+ draggedClipRef,
419
+ scrollRef,
420
+ cancelGestureRef,
421
+ sessionEpochRef,
422
+ lifecycleRef,
423
+ }),
424
+ );
425
+
426
+ useEffect(() => {
427
+ cancelGestureRef.current();
428
+ }, [sessionEpoch]);
386
429
 
387
430
  return {
388
431
  draggedClip,
@@ -391,7 +434,6 @@ export function useTimelineClipDrag({
391
434
  setResizingClip,
392
435
  blockedClipRef,
393
436
  suppressClickRef,
394
- syncClipDragAutoScroll,
395
437
  stopClipDragAutoScroll,
396
438
  };
397
439
  }
@@ -14,7 +14,7 @@ interface UseTimelineClipRenderWindowInput {
14
14
  duration: number;
15
15
  selectedElementId?: string;
16
16
  draggedElementId?: string;
17
- resizingElementId?: string;
17
+ resizingElementIds?: readonly string[];
18
18
  revealElementId?: string;
19
19
  focusedEaseElementId?: string;
20
20
  clipContextMenuElementId?: string;
@@ -36,7 +36,7 @@ export function useTimelineClipRenderWindow({
36
36
  duration,
37
37
  selectedElementId,
38
38
  draggedElementId,
39
- resizingElementId,
39
+ resizingElementIds,
40
40
  revealElementId,
41
41
  focusedEaseElementId,
42
42
  clipContextMenuElementId,
@@ -60,7 +60,7 @@ export function useTimelineClipRenderWindow({
60
60
  [
61
61
  selectedElementId,
62
62
  draggedElementId,
63
- resizingElementId,
63
+ ...(resizingElementIds ?? []),
64
64
  revealElementId,
65
65
  focusedEaseElementId,
66
66
  clipContextMenuElementId,
@@ -74,7 +74,7 @@ export function useTimelineClipRenderWindow({
74
74
  focusedEaseElementId,
75
75
  focusedElementId,
76
76
  keyframeContextMenuElementId,
77
- resizingElementId,
77
+ resizingElementIds,
78
78
  revealElementId,
79
79
  selectedElementId,
80
80
  ],
@@ -5,14 +5,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
6
6
  import type { TimelineElement } from "../store/playerStore";
7
7
  import { usePlayerStore } from "../store/playerStore";
8
+ import * as telemetry from "../../telemetry/events";
8
9
  import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
9
10
  import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
10
11
 
11
12
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
13
 
13
- const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
14
- vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
15
-
16
14
  const ELEMENT: TimelineElement = {
17
15
  id: "clip-1",
18
16
  label: "Hero card",
@@ -46,8 +44,8 @@ const COLLIDING_TARGET: TimelineKeyframeTarget = {
46
44
 
47
45
  afterEach(() => {
48
46
  document.body.innerHTML = "";
49
- trackStudioSegmentEaseEdit.mockClear();
50
- usePlayerStore.setState({ focusedEaseSegment: null });
47
+ vi.restoreAllMocks();
48
+ usePlayerStore.setState({ focusedEaseSegment: null, timelineSessionEpoch: 0 });
51
49
  });
52
50
 
53
51
  /**
@@ -78,6 +76,9 @@ function mountHandlers(options: Partial<Parameters<typeof useTimelineKeyframeHan
78
76
 
79
77
  describe("useTimelineKeyframeHandlers", () => {
80
78
  it("tracks opening the segment ease editor when a timeline segment is selected", () => {
79
+ const trackStudioSegmentEaseEdit = vi
80
+ .spyOn(telemetry, "trackStudioSegmentEaseEdit")
81
+ .mockImplementation(() => {});
81
82
  const { root, handlers } = mountHandlers();
82
83
  act(() => handlers.onSelectSegment?.(ELEMENT.id, TARGET));
83
84
 
@@ -90,7 +91,7 @@ describe("useTimelineKeyframeHandlers", () => {
90
91
  const { root, handlers } = mountHandlers();
91
92
  act(() => handlers.onSelectSegment?.(ELEMENT.id, COLLIDING_TARGET));
92
93
 
93
- expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
94
+ expect(usePlayerStore.getState().focusedEaseSegment).toMatchObject({
94
95
  animationId: "position-tween",
95
96
  collidingAnimationTargets: [
96
97
  { animationId: "position-tween", tweenPercentage: 100 },
@@ -111,7 +112,7 @@ describe("useTimelineKeyframeHandlers", () => {
111
112
  // Selecting a segment must NOT move the playhead.
112
113
  act(() => handlers.onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
113
114
  expect(onSeek).not.toHaveBeenCalled();
114
- expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
115
+ expect(usePlayerStore.getState().focusedEaseSegment).toMatchObject({
115
116
  animationId: "position-tween",
116
117
  tweenPercentage: 100,
117
118
  elementId: ELEMENT.id,
@@ -125,4 +126,39 @@ describe("useTimelineKeyframeHandlers", () => {
125
126
  expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
126
127
  act(() => root.unmount());
127
128
  });
129
+
130
+ it("scopes a keyframe context target to the opening timeline session", () => {
131
+ const setKfContextMenu = vi.fn();
132
+ usePlayerStore.setState({ timelineSessionEpoch: 4 });
133
+
134
+ function Harness() {
135
+ const { onContextMenuKeyframe } = useTimelineKeyframeHandlers({
136
+ expandedElements: [ELEMENT],
137
+ keyframeCache: new Map(),
138
+ setSelectedElementId: vi.fn(),
139
+ setKfContextMenu,
140
+ toggleSelectedKeyframe: vi.fn(),
141
+ });
142
+ return (
143
+ <button
144
+ type="button"
145
+ onContextMenu={(event) => onContextMenuKeyframe(event, ELEMENT.id, TARGET)}
146
+ />
147
+ );
148
+ }
149
+
150
+ const root = mountReactHarness(<Harness />);
151
+ const button = document.querySelector("button");
152
+ expect(button).not.toBeNull();
153
+ act(() => {
154
+ button?.dispatchEvent(
155
+ new MouseEvent("contextmenu", { bubbles: true, clientX: 10, clientY: 20 }),
156
+ );
157
+ });
158
+
159
+ expect(setKfContextMenu).toHaveBeenCalledWith(
160
+ expect.objectContaining({ elementId: ELEMENT.id, sessionEpoch: 4, x: 14, y: 22 }),
161
+ );
162
+ act(() => root.unmount());
163
+ });
128
164
  });