@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
@@ -10,6 +10,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
10
10
  import { getTimelineScrubTime } from "./timelineLayout";
11
11
  import {
12
12
  computeMarqueeSelection,
13
+ getMarqueeClipCandidates,
13
14
  getMarqueeRect,
14
15
  isMarqueeDrag,
15
16
  isTimelineRulerPress,
@@ -17,6 +18,7 @@ import {
17
18
  } from "./timelineMarquee";
18
19
  import type { Rect } from "../../utils/marqueeGeometry";
19
20
  import type { TimelineRowGeometry } from "./timelineLayout";
21
+ import type { TimelineClipIndex } from "../lib/timelineClipIndex";
20
22
 
21
23
  interface UseTimelineRangeSelectionInput {
22
24
  scrollRef: React.RefObject<HTMLDivElement | null>;
@@ -30,10 +32,11 @@ interface UseTimelineRangeSelectionInput {
30
32
  isDragging: React.RefObject<boolean>;
31
33
  setShowPopover: (v: boolean) => void;
32
34
  elementsRef: React.RefObject<TimelineElement[]>;
33
- trackOrderRef: React.RefObject<number[]>;
35
+ clipIndex: TimelineClipIndex;
34
36
  rowGeometryRef: React.RefObject<TimelineRowGeometry>;
35
37
  onSelectElement?: (element: TimelineElement | null) => void;
36
38
  contentOrigin: number;
39
+ sessionEpoch: number;
37
40
  }
38
41
 
39
42
  interface MarqueeDragState {
@@ -73,16 +76,21 @@ function commitMarqueeSelection(
73
76
  rect: Rect,
74
77
  additive: boolean,
75
78
  marquee: MarqueeDragState,
76
- elements: TimelineElement[],
77
- trackOrder: number[],
78
- rowHeights: readonly number[],
79
+ clipIndex: TimelineClipIndex,
80
+ rowGeometry: TimelineRowGeometry,
79
81
  pps: number,
80
82
  contentOrigin: number,
81
83
  ): void {
84
+ const candidates = getMarqueeClipCandidates({
85
+ clipIndex,
86
+ rowGeometry,
87
+ marquee: rect,
88
+ pps,
89
+ contentOrigin,
90
+ });
82
91
  const { ids, primaryId } = computeMarqueeSelection({
83
- clips: toMarqueeClips(elements),
84
- trackOrder,
85
- rowHeights,
92
+ clips: toMarqueeClips([...candidates]),
93
+ rowGeometry,
86
94
  pps,
87
95
  contentOrigin,
88
96
  marquee: rect,
@@ -95,6 +103,28 @@ function commitMarqueeSelection(
95
103
  store.setSelectedElementIds(ids);
96
104
  }
97
105
 
106
+ function canStartPointerGesture(
107
+ event: React.PointerEvent,
108
+ activePointerId: number | null,
109
+ sessionEpoch: number,
110
+ ): boolean {
111
+ return (
112
+ event.button === 0 &&
113
+ activePointerId === null &&
114
+ sessionEpoch === usePlayerStore.getState().timelineSessionEpoch
115
+ );
116
+ }
117
+
118
+ function isMarqueePress(
119
+ point: { x: number; y: number } | null,
120
+ scrollRect: DOMRect | undefined,
121
+ clientY: number,
122
+ ): point is { x: number; y: number } {
123
+ return (
124
+ point !== null && scrollRect !== undefined && !isTimelineRulerPress(clientY, scrollRect.top)
125
+ );
126
+ }
127
+
98
128
  export function useTimelineRangeSelection({
99
129
  scrollRef,
100
130
  ppsRef,
@@ -107,10 +137,11 @@ export function useTimelineRangeSelection({
107
137
  isDragging,
108
138
  setShowPopover,
109
139
  elementsRef,
110
- trackOrderRef,
140
+ clipIndex,
111
141
  rowGeometryRef,
112
142
  onSelectElement,
113
143
  contentOrigin,
144
+ sessionEpoch,
114
145
  }: UseTimelineRangeSelectionInput) {
115
146
  const isRangeSelecting = useRef(false);
116
147
  const rangeAnchorTime = useRef(0);
@@ -126,6 +157,17 @@ export function useTimelineRangeSelection({
126
157
 
127
158
  const seekRafRef = useRef(0);
128
159
  const pendingClientXRef = useRef(0);
160
+ const activePointerIdRef = useRef<number | null>(null);
161
+ const gestureEpochRef = useRef<number | null>(null);
162
+ const sessionEpochRef = useRef(sessionEpoch);
163
+ sessionEpochRef.current = sessionEpoch;
164
+
165
+ const isGestureSessionCurrent = useCallback(
166
+ () =>
167
+ gestureEpochRef.current === sessionEpochRef.current &&
168
+ gestureEpochRef.current === usePlayerStore.getState().timelineSessionEpoch,
169
+ [],
170
+ );
129
171
 
130
172
  // Marquee (rubber-band) multi-select on the empty timeline body.
131
173
  const marqueeRef = useRef<MarqueeDragState | null>(null);
@@ -159,7 +201,7 @@ export function useTimelineRangeSelection({
159
201
  const applyMarqueeAtClient = useCallback(
160
202
  (clientX: number, clientY: number, shiftKey: boolean) => {
161
203
  const marquee = marqueeRef.current;
162
- if (!marquee) return;
204
+ if (!marquee || !isGestureSessionCurrent()) return;
163
205
  const point = toContentPoint(clientX, clientY);
164
206
  if (!point) return;
165
207
  if (!marquee.active && !isMarqueeDrag(marquee.originX, marquee.originY, point.x, point.y)) {
@@ -175,14 +217,13 @@ export function useTimelineRangeSelection({
175
217
  rect,
176
218
  additive,
177
219
  marquee,
178
- elementsRef.current ?? [],
179
- trackOrderRef.current ?? [],
180
- rowGeometryRef.current.rowHeights,
220
+ clipIndex,
221
+ rowGeometryRef.current,
181
222
  ppsRef.current,
182
223
  contentOrigin,
183
224
  );
184
225
  },
185
- [toContentPoint, elementsRef, trackOrderRef, rowGeometryRef, ppsRef, contentOrigin],
226
+ [toContentPoint, isGestureSessionCurrent, clipIndex, rowGeometryRef, ppsRef, contentOrigin],
186
227
  );
187
228
 
188
229
  const stopMarqueeAutoScroll = useCallback(() => {
@@ -203,14 +244,16 @@ export function useTimelineRangeSelection({
203
244
  const marquee = marqueeRef.current;
204
245
  const pointer = marqueePointerRef.current;
205
246
  const scroll = scrollRef.current;
206
- if (!marquee || !pointer || !scroll) return;
247
+ if (!marquee || !pointer || !scroll || !isGestureSessionCurrent()) {
248
+ return;
249
+ }
207
250
  if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
208
251
 
209
252
  // Re-run at the SAME client point: toContentPoint folds in the new scroll, so
210
253
  // the marquee's moving corner tracks the revealed content.
211
254
  applyMarqueeAtClient(pointer.clientX, pointer.clientY, pointer.shiftKey);
212
255
  marqueeScrollRaf.current = requestAnimationFrame(stepMarqueeAutoScroll);
213
- }, [scrollRef, applyMarqueeAtClient]);
256
+ }, [scrollRef, applyMarqueeAtClient, isGestureSessionCurrent]);
214
257
 
215
258
  const syncMarqueeAutoScroll = useCallback(
216
259
  (clientX: number, clientY: number, shiftKey: boolean) => {
@@ -235,6 +278,8 @@ export function useTimelineRangeSelection({
235
278
  const beginRangeSelection = useCallback(
236
279
  (e: React.PointerEvent) => {
237
280
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
281
+ activePointerIdRef.current = e.pointerId;
282
+ gestureEpochRef.current = sessionEpochRef.current;
238
283
  isRangeSelecting.current = true;
239
284
  setShowPopover(false);
240
285
  const rect = scrollRef.current?.getBoundingClientRect();
@@ -248,9 +293,31 @@ export function useTimelineRangeSelection({
248
293
  [scrollRef, pps, setShowPopover, contentOrigin],
249
294
  );
250
295
 
296
+ const beginScrub = useCallback(
297
+ (clientX: number) => {
298
+ isDragging.current = true;
299
+ setIsScrubbing(true);
300
+ pendingClientXRef.current = clientX;
301
+ seekFromX(clientX);
302
+ },
303
+ [isDragging, seekFromX],
304
+ );
305
+
306
+ const beginMarquee = useCallback((point: { x: number; y: number }, additive: boolean) => {
307
+ const base = snapshotSelection();
308
+ marqueeRef.current = {
309
+ originX: point.x,
310
+ originY: point.y,
311
+ baseIds: base.ids,
312
+ basePrimary: base.primary,
313
+ additive,
314
+ active: false,
315
+ };
316
+ }, []);
317
+
251
318
  const handlePointerDown = useCallback(
252
319
  (e: React.PointerEvent) => {
253
- if (e.button !== 0) return;
320
+ if (!canStartPointerGesture(e, activePointerIdRef.current, sessionEpochRef.current)) return;
254
321
  if (e.shiftKey) {
255
322
  beginRangeSelection(e);
256
323
  return;
@@ -258,6 +325,8 @@ export function useTimelineRangeSelection({
258
325
  shiftClickClipRef.current = null;
259
326
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
260
327
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
328
+ activePointerIdRef.current = e.pointerId;
329
+ gestureEpochRef.current = sessionEpochRef.current;
261
330
  setRangeSelection(null);
262
331
  setShowPopover(false);
263
332
  const point = toContentPoint(e.clientX, e.clientY);
@@ -266,30 +335,15 @@ export function useTimelineRangeSelection({
266
335
  // y (which folds in scrollTop) breaks once the body is scrolled down and
267
336
  // the stuck ruler visually overlays scrolled-away track rows.
268
337
  const scrollRect = scrollRef.current?.getBoundingClientRect();
269
- if (!point || !scrollRect || isTimelineRulerPress(e.clientY, scrollRect.top)) {
270
- isDragging.current = true;
271
- setIsScrubbing(true);
272
- // Seed the pending coordinate so a press with no pointermove still
273
- // replays THIS x on pointerup. `updateScrubDrag` is the only other
274
- // writer, so without this a plain click settles on the ref's initial
275
- // 0 and clamps the playhead back to t=0.
276
- pendingClientXRef.current = e.clientX;
277
- seekFromX(e.clientX);
338
+ if (!isMarqueePress(point, scrollRect, e.clientY)) {
339
+ beginScrub(e.clientX);
278
340
  return;
279
341
  }
280
342
  // Empty body press → pending marquee. A plain click (no drag past the
281
343
  // threshold) deselects on pointerup; a drag draws the marquee. Never scrubs.
282
- const base = snapshotSelection();
283
- marqueeRef.current = {
284
- originX: point.x,
285
- originY: point.y,
286
- baseIds: base.ids,
287
- basePrimary: base.primary,
288
- additive: e.metaKey || e.ctrlKey,
289
- active: false,
290
- };
344
+ beginMarquee(point, e.metaKey || e.ctrlKey);
291
345
  },
292
- [beginRangeSelection, seekFromX, scrollRef, isDragging, setShowPopover, toContentPoint],
346
+ [beginRangeSelection, beginScrub, beginMarquee, scrollRef, setShowPopover, toContentPoint],
293
347
  );
294
348
 
295
349
  // Scrub-drag update: live playhead feedback (liveTime) + RAF-throttled seek.
@@ -325,18 +379,27 @@ export function useTimelineRangeSelection({
325
379
  [scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging, contentOrigin],
326
380
  );
327
381
 
382
+ const updateRangeSelection = useCallback(
383
+ (e: React.PointerEvent) => {
384
+ const scroll = scrollRef.current;
385
+ const rect = scroll?.getBoundingClientRect();
386
+ if (!scroll || !rect) return;
387
+ const x = e.clientX - rect.left + scroll.scrollLeft - contentOrigin;
388
+ setRangeSelection((previous) =>
389
+ previous
390
+ ? { ...previous, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
391
+ : null,
392
+ );
393
+ },
394
+ [contentOrigin, pps, scrollRef],
395
+ );
396
+
328
397
  const handlePointerMove = useCallback(
329
398
  (e: React.PointerEvent) => {
399
+ if (!isGestureSessionCurrent()) return;
400
+ if (activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current) return;
330
401
  if (isRangeSelecting.current) {
331
- const rect = scrollRef.current?.getBoundingClientRect();
332
- if (rect) {
333
- const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
334
- setRangeSelection((prev) =>
335
- prev
336
- ? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
337
- : null,
338
- );
339
- }
402
+ updateRangeSelection(e);
340
403
  return;
341
404
  }
342
405
  const marquee = marqueeRef.current;
@@ -351,13 +414,12 @@ export function useTimelineRangeSelection({
351
414
  updateScrubDrag(e.clientX);
352
415
  },
353
416
  [
354
- pps,
355
- scrollRef,
356
417
  isDragging,
357
418
  applyMarqueeAtClient,
358
419
  syncMarqueeAutoScroll,
359
420
  updateScrubDrag,
360
- contentOrigin,
421
+ updateRangeSelection,
422
+ isGestureSessionCurrent,
361
423
  ],
362
424
  );
363
425
 
@@ -403,17 +465,15 @@ export function useTimelineRangeSelection({
403
465
  [stopMarqueeAutoScroll, elementsRef, onSelectElement],
404
466
  );
405
467
 
406
- const handlePointerUp = useCallback(() => {
407
- if (isRangeSelecting.current) {
408
- finishRangeSelection();
409
- return;
410
- }
411
- const marquee = marqueeRef.current;
412
- if (marquee) {
413
- finishMarquee(marquee);
414
- return;
415
- }
416
- if (!isDragging.current) return;
468
+ const canFinishPointerGesture = useCallback(
469
+ (e?: React.PointerEvent) => {
470
+ const pointerId = activePointerIdRef.current;
471
+ return pointerId !== null && (!e || e.pointerId === pointerId) && isGestureSessionCurrent();
472
+ },
473
+ [isGestureSessionCurrent],
474
+ );
475
+
476
+ const finishScrub = useCallback(() => {
417
477
  if (seekRafRef.current) {
418
478
  cancelAnimationFrame(seekRafRef.current);
419
479
  seekRafRef.current = 0;
@@ -422,7 +482,69 @@ export function useTimelineRangeSelection({
422
482
  isDragging.current = false;
423
483
  setIsScrubbing(false);
424
484
  cancelAnimationFrame(dragScrollRaf.current);
425
- }, [isDragging, dragScrollRaf, seekFromX, finishRangeSelection, finishMarquee]);
485
+ }, [dragScrollRaf, isDragging, seekFromX]);
486
+
487
+ const handlePointerUp = useCallback(
488
+ (e?: React.PointerEvent) => {
489
+ if (!canFinishPointerGesture(e)) return;
490
+ activePointerIdRef.current = null;
491
+ gestureEpochRef.current = null;
492
+ if (isRangeSelecting.current) {
493
+ finishRangeSelection();
494
+ return;
495
+ }
496
+ const marquee = marqueeRef.current;
497
+ if (marquee) {
498
+ finishMarquee(marquee);
499
+ return;
500
+ }
501
+ if (isDragging.current) finishScrub();
502
+ },
503
+ [canFinishPointerGesture, finishRangeSelection, finishMarquee, finishScrub, isDragging],
504
+ );
505
+
506
+ const cancelActiveGesture = useCallback(
507
+ (updateUi: boolean, restoreSelection: boolean) => {
508
+ activePointerIdRef.current = null;
509
+ gestureEpochRef.current = null;
510
+ isRangeSelecting.current = false;
511
+ isDragging.current = false;
512
+ stopMarqueeAutoScroll();
513
+ if (seekRafRef.current) {
514
+ cancelAnimationFrame(seekRafRef.current);
515
+ seekRafRef.current = 0;
516
+ }
517
+ cancelAnimationFrame(dragScrollRaf.current);
518
+ dragScrollRaf.current = 0;
519
+
520
+ const marquee = marqueeRef.current;
521
+ marqueeRef.current = null;
522
+ if (restoreSelection && marquee?.active) {
523
+ const store = usePlayerStore.getState();
524
+ store.setSelectedElementId(marquee.basePrimary);
525
+ store.setSelectedElementIds(marquee.baseIds);
526
+ }
527
+ if (updateUi) {
528
+ setMarqueeRect(null);
529
+ setRangeSelection(null);
530
+ setIsScrubbing(false);
531
+ }
532
+ },
533
+ [dragScrollRaf, isDragging, stopMarqueeAutoScroll],
534
+ );
535
+
536
+ const handlePointerCancel = useCallback(
537
+ (e?: React.PointerEvent) => {
538
+ if (
539
+ activePointerIdRef.current === null ||
540
+ (e && activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current)
541
+ ) {
542
+ return;
543
+ }
544
+ cancelActiveGesture(true, isGestureSessionCurrent());
545
+ },
546
+ [cancelActiveGesture, isGestureSessionCurrent],
547
+ );
426
548
 
427
549
  // Escape: cancel an in-flight marquee (restores the pre-drag selection);
428
550
  // otherwise clear any lingering multi-selection.
@@ -432,15 +554,11 @@ export function useTimelineRangeSelection({
432
554
  const store = usePlayerStore.getState();
433
555
  const marquee = marqueeRef.current;
434
556
  if (marquee) {
435
- marqueeRef.current = null;
436
- stopMarqueeAutoScroll();
437
- setMarqueeRect(null);
438
- if (marquee.active) {
439
- // Primary FIRST (see commitMarqueeSelection): it collapses the set, so
440
- // restore the pre-drag primary before repopulating the base ids.
441
- store.setSelectedElementId(marquee.basePrimary);
442
- store.setSelectedElementIds(marquee.baseIds);
443
- }
557
+ cancelActiveGesture(true, true);
558
+ return;
559
+ }
560
+ if (isRangeSelecting.current || isDragging.current) {
561
+ cancelActiveGesture(true, true);
444
562
  return;
445
563
  }
446
564
  // Escape with no marquee clears the whole selection — primary AND set.
@@ -451,7 +569,21 @@ export function useTimelineRangeSelection({
451
569
  };
452
570
  window.addEventListener("keydown", onKeyDown);
453
571
  return () => window.removeEventListener("keydown", onKeyDown);
454
- }, [stopMarqueeAutoScroll]);
572
+ }, [cancelActiveGesture, isDragging]);
573
+
574
+ const previousSessionEpochRef = useRef(sessionEpoch);
575
+ useEffect(() => {
576
+ if (previousSessionEpochRef.current === sessionEpoch) return;
577
+ previousSessionEpochRef.current = sessionEpoch;
578
+ cancelActiveGesture(true, false);
579
+ }, [cancelActiveGesture, sessionEpoch]);
580
+
581
+ useEffect(
582
+ () => () => {
583
+ cancelActiveGesture(false, isGestureSessionCurrent());
584
+ },
585
+ [cancelActiveGesture, isGestureSessionCurrent],
586
+ );
455
587
 
456
588
  return {
457
589
  rangeSelection,
@@ -462,5 +594,6 @@ export function useTimelineRangeSelection({
462
594
  handlePointerDown,
463
595
  handlePointerMove,
464
596
  handlePointerUp,
597
+ handlePointerCancel,
465
598
  };
466
599
  }
@@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
9
9
  import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
10
10
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
11
11
  import { getTimelineRowGeometry } from "./timelineLayout";
12
+ import { createTimelineClipIndex } from "../lib/timelineClipIndex";
12
13
 
13
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
15
 
@@ -60,7 +61,6 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
60
61
  const dragScrollRaf = { current: 0 };
61
62
  const isDragging = { current: false };
62
63
  const elementsRef = { current: [] };
63
- const trackOrderRef = { current: [] };
64
64
  const rowGeometryRef = { current: getTimelineRowGeometry([]) };
65
65
 
66
66
  function Probe(): null {
@@ -75,7 +75,8 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
75
75
  isDragging,
76
76
  setShowPopover: vi.fn(),
77
77
  elementsRef,
78
- trackOrderRef,
78
+ clipIndex: createTimelineClipIndex([]),
79
+ sessionEpoch: 0,
79
80
  rowGeometryRef,
80
81
  contentOrigin: 0,
81
82
  });
@@ -24,7 +24,7 @@ interface UseTimelineRowVirtualizationInput {
24
24
  selectedElementId: string | null;
25
25
  revealElementId: string | null;
26
26
  draggedRowKey?: number;
27
- resizingRowKey?: number;
27
+ resizingElementIds?: readonly string[];
28
28
  clipContextMenuRowKey?: number;
29
29
  keyframeContextMenuRowKey?: number;
30
30
  lastScrollLeftRef: RefObject<number>;
@@ -55,7 +55,7 @@ export function useTimelineRowVirtualization({
55
55
  selectedElementId,
56
56
  revealElementId,
57
57
  draggedRowKey,
58
- resizingRowKey,
58
+ resizingElementIds,
59
59
  clipContextMenuRowKey,
60
60
  keyframeContextMenuRowKey,
61
61
  lastScrollLeftRef,
@@ -77,11 +77,18 @@ export function useTimelineRowVirtualization({
77
77
  () => resolveTimelineFocusIdentity(elements, revealElementId),
78
78
  [elements, revealElementId],
79
79
  );
80
+ const resizingRowKeys = useMemo(
81
+ () =>
82
+ resizingElementIds
83
+ ?.map((elementId) => resolveTimelineFocusIdentity(elements, elementId)?.rowKey)
84
+ .filter((rowKey): rowKey is number => rowKey !== undefined) ?? [],
85
+ [elements, resizingElementIds],
86
+ );
80
87
  const pinnedRowKeys = useMemo(
81
88
  () =>
82
89
  [
83
90
  draggedRowKey,
84
- resizingRowKey,
91
+ ...resizingRowKeys,
85
92
  revealIdentity?.rowKey,
86
93
  clipContextMenuRowKey,
87
94
  keyframeContextMenuRowKey,
@@ -90,7 +97,7 @@ export function useTimelineRowVirtualization({
90
97
  clipContextMenuRowKey,
91
98
  draggedRowKey,
92
99
  keyframeContextMenuRowKey,
93
- resizingRowKey,
100
+ resizingRowKeys,
94
101
  revealIdentity,
95
102
  ],
96
103
  );
@@ -0,0 +1,64 @@
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 { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
+ import { useTrackGapMenu } from "./useTrackGapMenu";
8
+
9
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const lane: TimelineElement[] = [
12
+ { id: "first", tag: "div", start: 0, duration: 1, track: 0 },
13
+ { id: "second", tag: "div", start: 3, duration: 1, track: 0 },
14
+ ];
15
+
16
+ afterEach(() => {
17
+ usePlayerStore.getState().reset();
18
+ document.body.innerHTML = "";
19
+ });
20
+
21
+ describe("useTrackGapMenu", () => {
22
+ it("does not commit an anchor captured in a previous project session", () => {
23
+ usePlayerStore.setState({ elements: lane, timelineSessionEpoch: 1 });
24
+ const host = document.createElement("div");
25
+ document.body.append(host);
26
+ const root = createRoot(host);
27
+ const onMoveElement = vi.fn();
28
+ const onMoveElements = vi.fn();
29
+ let api: ReturnType<typeof useTrackGapMenu> | null = null;
30
+
31
+ function getApi(): ReturnType<typeof useTrackGapMenu> {
32
+ if (!api) throw new Error("gap menu harness did not render");
33
+ return api;
34
+ }
35
+
36
+ function Probe() {
37
+ api = useTrackGapMenu({
38
+ tracks: [[0, lane]],
39
+ expandedElementsRef: { current: lane },
40
+ trackOrderRef: { current: [0] },
41
+ onMoveElement,
42
+ onMoveElements,
43
+ });
44
+ return null;
45
+ }
46
+
47
+ act(() => root.render(<Probe />));
48
+ act(() => getApi().openGapMenu({ x: 10, y: 20, track: 0, time: 2 }));
49
+ expect(getApi().gapMenuModel).not.toBeNull();
50
+ const staleCloseTrackGap = getApi().closeTrackGap;
51
+ const staleCloseAllTrackGaps = getApi().closeAllTrackGaps;
52
+
53
+ act(() => {
54
+ usePlayerStore.setState({ timelineSessionEpoch: 2 });
55
+ staleCloseTrackGap();
56
+ staleCloseAllTrackGaps();
57
+ });
58
+ expect(getApi().gapMenuModel).toBeNull();
59
+
60
+ expect(onMoveElement).not.toHaveBeenCalled();
61
+ expect(onMoveElements).not.toHaveBeenCalled();
62
+ act(() => root.unmount());
63
+ });
64
+ });
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useState, type MutableRefObject } from "react";
1
+ import { useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
3
  import type { DragCommitDeps } from "./timelineClipDragCommit";
4
4
  import {
@@ -21,6 +21,7 @@ interface TrackGapMenuAnchor {
21
21
  y: number;
22
22
  track: number;
23
23
  time: number;
24
+ sessionEpoch: number;
24
25
  }
25
26
 
26
27
  /** Gap strips to paint on one lane while a menu row is hovered. */
@@ -32,9 +33,9 @@ export interface TrackGapHighlight {
32
33
  /**
33
34
  * Track-gap context menu (right-click on empty lane space) — state, the
34
35
  * derived menu model, and the two commit actions. Extracted from Timeline.tsx
35
- * as a cohesive unit (600-line studio cap); behavior identical.
36
+ * as a cohesive unit (600-line studio cap).
36
37
  *
37
- * Only the ANCHOR (and the hovered row) is state; the menu model (gap under
38
+ * Only the session-stamped ANCHOR (and the hovered row) is state; the menu model (gap under
38
39
  * the pointer, compaction, movability) derives from live `tracks` so an open
39
40
  * menu reflects concurrent edits. `gapHighlight` — the strips TimelineCanvas
40
41
  * paints while "Close gap" / "Close all gaps" is hovered — derives the same
@@ -55,12 +56,16 @@ export function useTrackGapMenu({
55
56
  onMoveElements: DragCommitDeps["onMoveElements"];
56
57
  }) {
57
58
  const updateElement = usePlayerStore((s) => s.updateElement);
59
+ const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
58
60
  const [gapContextMenu, setGapContextMenu] = useState<TrackGapMenuAnchor | null>(null);
59
61
  const [hoveredGapAction, setHoveredGapAction] = useState<"close-gap" | "close-all" | null>(null);
60
62
 
61
63
  const gapMenuLaneElements = useMemo(
62
- () => (gapContextMenu ? (tracks.find(([t]) => t === gapContextMenu.track)?.[1] ?? []) : null),
63
- [gapContextMenu, tracks],
64
+ () =>
65
+ gapContextMenu?.sessionEpoch === sessionEpoch
66
+ ? (tracks.find(([t]) => t === gapContextMenu.track)?.[1] ?? [])
67
+ : null,
68
+ [gapContextMenu, sessionEpoch, tracks],
64
69
  );
65
70
  const gapMenuModel = useMemo(() => {
66
71
  if (!gapContextMenu || !gapMenuLaneElements) return null;
@@ -99,7 +104,12 @@ export function useTrackGapMenu({
99
104
  }, [gapContextMenu, gapMenuLaneElements, hoveredGapAction]);
100
105
 
101
106
  const closeTrackGap = useCallback(() => {
102
- if (!gapContextMenu || !gapMenuLaneElements) return;
107
+ if (
108
+ !gapContextMenu ||
109
+ !gapMenuLaneElements ||
110
+ gapContextMenu.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch
111
+ )
112
+ return;
103
113
  commitCloseTrackGap(gapMenuLaneElements, gapContextMenu.time, {
104
114
  elements: expandedElementsRef.current,
105
115
  trackOrder: trackOrderRef.current,
@@ -117,7 +127,12 @@ export function useTrackGapMenu({
117
127
  onMoveElements,
118
128
  ]);
119
129
  const closeAllTrackGaps = useCallback(() => {
120
- if (!gapMenuLaneElements) return;
130
+ if (
131
+ !gapContextMenu ||
132
+ !gapMenuLaneElements ||
133
+ gapContextMenu.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch
134
+ )
135
+ return;
121
136
  commitCloseAllTrackGaps(gapMenuLaneElements, {
122
137
  elements: expandedElementsRef.current,
123
138
  trackOrder: trackOrderRef.current,
@@ -126,6 +141,7 @@ export function useTrackGapMenu({
126
141
  onMoveElements,
127
142
  });
128
143
  }, [
144
+ gapContextMenu,
129
145
  gapMenuLaneElements,
130
146
  expandedElementsRef,
131
147
  trackOrderRef,
@@ -134,15 +150,22 @@ export function useTrackGapMenu({
134
150
  onMoveElements,
135
151
  ]);
136
152
 
137
- const openGapMenu = useCallback((anchor: TrackGapMenuAnchor) => {
153
+ const openGapMenu = useCallback((anchor: Omit<TrackGapMenuAnchor, "sessionEpoch">) => {
138
154
  setHoveredGapAction(null);
139
- setGapContextMenu(anchor);
155
+ setGapContextMenu({
156
+ ...anchor,
157
+ sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
158
+ });
140
159
  }, []);
141
160
  const dismissGapMenu = useCallback(() => {
142
161
  setHoveredGapAction(null);
143
162
  setGapContextMenu(null);
144
163
  }, []);
145
164
 
165
+ useEffect(() => {
166
+ if (gapContextMenu && gapContextMenu.sessionEpoch !== sessionEpoch) dismissGapMenu();
167
+ }, [dismissGapMenu, gapContextMenu, sessionEpoch]);
168
+
146
169
  return {
147
170
  gapMenuModel,
148
171
  gapHighlight,