@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -0,0 +1,204 @@
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 { createTimelineRowGeometry, getTimelineRowTop } from "./timelineLayout";
8
+ import { createTimelineClipIndex } from "../lib/timelineClipIndex";
9
+ import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
10
+ import { configureTimelineTestViewport } from "./timelineTestViewport";
11
+
12
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ const elements: TimelineElement[] = [
15
+ { id: "first", tag: "div", start: 1, duration: 1, track: 0 },
16
+ { id: "offscreen", tag: "div", start: 2, duration: 1, track: 50 },
17
+ { id: "base", tag: "div", start: 8, duration: 1, track: 99 },
18
+ ];
19
+ const tracks = Array.from({ length: 100 }, (_, index) => index);
20
+ const geometry = createTimelineRowGeometry(
21
+ tracks,
22
+ tracks.map(() => 48),
23
+ );
24
+ const clipIndex = createTimelineClipIndex(
25
+ tracks.map((track) => [track, elements.filter((element) => element.track === track)]),
26
+ );
27
+ const FIRST_ROW_Y = getTimelineRowTop(0) + 4;
28
+ const OFFSCREEN_ROW_Y = getTimelineRowTop(50) + 40;
29
+
30
+ function pointer(
31
+ currentTarget: HTMLElement,
32
+ pointerId: number,
33
+ clientX: number,
34
+ clientY: number,
35
+ init: Partial<React.PointerEvent> = {},
36
+ ): React.PointerEvent {
37
+ return {
38
+ button: 0,
39
+ clientX,
40
+ clientY,
41
+ ctrlKey: false,
42
+ metaKey: false,
43
+ shiftKey: false,
44
+ pointerId,
45
+ currentTarget,
46
+ target: currentTarget,
47
+ ...init,
48
+ } as React.PointerEvent;
49
+ }
50
+
51
+ function renderHarness(sessionEpoch = 1) {
52
+ usePlayerStore.setState({ timelineSessionEpoch: sessionEpoch });
53
+ const host = document.createElement("div");
54
+ const scroll = document.createElement("div");
55
+ scroll.setPointerCapture = vi.fn();
56
+ configureTimelineTestViewport(scroll, geometry.canvasHeight);
57
+ host.append(scroll);
58
+ document.body.append(host);
59
+ const root = createRoot(host);
60
+ let api: ReturnType<typeof useTimelineRangeSelection> | null = null;
61
+ const ppsRef = { current: 100 };
62
+ const dragScrollRaf = { current: 0 };
63
+ const isDragging = { current: false };
64
+ const elementsRef = { current: elements };
65
+ const rowGeometryRef = { current: geometry };
66
+ const seekFromX = vi.fn();
67
+
68
+ function Probe({ epoch }: { epoch: number }) {
69
+ api = useTimelineRangeSelection({
70
+ scrollRef: { current: scroll },
71
+ ppsRef,
72
+ effectiveDuration: 60,
73
+ pps: 100,
74
+ seekFromX,
75
+ autoScrollDuringDrag: vi.fn(),
76
+ dragScrollRaf,
77
+ isDragging,
78
+ setShowPopover: vi.fn(),
79
+ elementsRef,
80
+ clipIndex,
81
+ rowGeometryRef,
82
+ contentOrigin: 0,
83
+ sessionEpoch: epoch,
84
+ });
85
+ return null;
86
+ }
87
+
88
+ act(() => root.render(<Probe epoch={sessionEpoch} />));
89
+ return {
90
+ scroll,
91
+ root,
92
+ get api() {
93
+ if (!api) throw new Error("selection harness did not render");
94
+ return api;
95
+ },
96
+ rerender(epoch: number) {
97
+ usePlayerStore.setState({ timelineSessionEpoch: epoch });
98
+ act(() => root.render(<Probe epoch={epoch} />));
99
+ },
100
+ seekFromX,
101
+ };
102
+ }
103
+
104
+ function dragMarquee(
105
+ view: ReturnType<typeof renderHarness>,
106
+ options: { secondPointer?: boolean; release?: boolean } = {},
107
+ ): void {
108
+ act(() => {
109
+ view.api.handlePointerDown(pointer(view.scroll, 7, 0, FIRST_ROW_Y));
110
+ if (options.secondPointer) {
111
+ view.api.handlePointerDown(pointer(view.scroll, 8, 500, FIRST_ROW_Y));
112
+ }
113
+ view.api.handlePointerMove(pointer(view.scroll, 7, 400, OFFSCREEN_ROW_Y));
114
+ if (options.release) {
115
+ view.api.handlePointerUp(pointer(view.scroll, 7, 400, OFFSCREEN_ROW_Y));
116
+ }
117
+ });
118
+ }
119
+
120
+ function expectSelectedIds(...ids: string[]): void {
121
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(ids));
122
+ }
123
+
124
+ function unmountHarness(view: ReturnType<typeof renderHarness>): void {
125
+ act(() => view.root.unmount());
126
+ }
127
+
128
+ afterEach(() => {
129
+ usePlayerStore.getState().reset();
130
+ document.body.innerHTML = "";
131
+ });
132
+
133
+ describe("useTimelineRangeSelection", () => {
134
+ it("marquee-selects model clips across unmounted virtual rows", () => {
135
+ const view = renderHarness();
136
+ dragMarquee(view);
137
+
138
+ expect(document.querySelectorAll("[data-clip]")).toHaveLength(0);
139
+ expectSelectedIds("first", "offscreen");
140
+ unmountHarness(view);
141
+ });
142
+
143
+ it("ignores another pointer and restores the pre-drag selection on cancellation", () => {
144
+ usePlayerStore.getState().setSelectedElementId("base");
145
+ const view = renderHarness();
146
+ dragMarquee(view);
147
+ act(() => view.api.handlePointerUp(pointer(view.scroll, 8, 400, OFFSCREEN_ROW_Y)));
148
+ expectSelectedIds("first", "offscreen");
149
+
150
+ act(() => view.api.handlePointerCancel(pointer(view.scroll, 7, 400, OFFSCREEN_ROW_Y)));
151
+ expect(usePlayerStore.getState().selectedElementId).toBe("base");
152
+ expectSelectedIds("base");
153
+ unmountHarness(view);
154
+ });
155
+
156
+ it("keeps the original pointer owner when a second pointer presses", () => {
157
+ const view = renderHarness();
158
+ dragMarquee(view, { secondPointer: true, release: true });
159
+
160
+ expectSelectedIds("first", "offscreen");
161
+ unmountHarness(view);
162
+ });
163
+
164
+ it("commits a ruler click at its pointerdown position without requiring pointer movement", () => {
165
+ const view = renderHarness();
166
+
167
+ act(() => {
168
+ view.api.handlePointerDown(pointer(view.scroll, 7, 375, 5));
169
+ view.api.handlePointerUp(pointer(view.scroll, 7, 375, 5));
170
+ });
171
+
172
+ expect(view.seekFromX).toHaveBeenNthCalledWith(1, 375);
173
+ expect(view.seekFromX).toHaveBeenNthCalledWith(2, 375);
174
+ unmountHarness(view);
175
+ });
176
+
177
+ it("does not clear a finalized range when capture is lost after pointerup", () => {
178
+ const view = renderHarness();
179
+
180
+ act(() => {
181
+ view.api.handlePointerDown(pointer(view.scroll, 7, 100, 80, { shiftKey: true }));
182
+ view.api.handlePointerMove(pointer(view.scroll, 7, 300, 80, { shiftKey: true }));
183
+ view.api.handlePointerUp(pointer(view.scroll, 7, 300, 80, { shiftKey: true }));
184
+ });
185
+ const finalized = view.api.rangeSelection;
186
+ expect(finalized).toMatchObject({ start: 1, end: 3 });
187
+
188
+ act(() => view.api.handlePointerCancel(pointer(view.scroll, 7, 300, 80)));
189
+ expect(view.api.rangeSelection).toEqual(finalized);
190
+ unmountHarness(view);
191
+ });
192
+
193
+ it("cancels a live marquee when the project session changes", () => {
194
+ usePlayerStore.getState().setSelectedElementId("base");
195
+ const view = renderHarness(1);
196
+ dragMarquee(view);
197
+ usePlayerStore.getState().setSelectedElementId("base");
198
+ view.rerender(2);
199
+
200
+ expect(usePlayerStore.getState().selectedElementId).toBe("base");
201
+ expectSelectedIds("base");
202
+ unmountHarness(view);
203
+ });
204
+ });
@@ -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
  });