@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,374 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import { BeatStrip } from "./BeatStrip";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ const roots: Root[] = [];
13
+ const requestSeek = vi.fn();
14
+ const commitBeatEdits = usePlayerStore.getState().commitBeatEdits;
15
+ const commitBeatEditsSpy = vi.fn((...args: Parameters<typeof commitBeatEdits>) =>
16
+ commitBeatEdits(...args),
17
+ );
18
+ const MUSIC_ELEMENT: TimelineElement = {
19
+ id: "background-music",
20
+ label: "Music",
21
+ tag: "audio",
22
+ src: "music.mp3",
23
+ start: 0,
24
+ duration: 10,
25
+ track: 0,
26
+ timelineRole: "music",
27
+ };
28
+ const BEAT_ANALYSIS = {
29
+ beatTimes: [1, 3],
30
+ beatStrengths: [0.5, 0.8],
31
+ bpm: 120,
32
+ bpmConfidence: "high" as const,
33
+ channelData: null,
34
+ sampleRate: 48_000,
35
+ peak: 1,
36
+ };
37
+
38
+ function pointerEvent(type: string, init: PointerEventInit): Event {
39
+ if (typeof PointerEvent === "function") return new PointerEvent(type, init);
40
+ const event = new MouseEvent(type, init);
41
+ Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 0 });
42
+ return event;
43
+ }
44
+
45
+ function mountBeatStrip(renderTimeRange?: { start: number; end: number }) {
46
+ const viewport = document.createElement("div");
47
+ viewport.dataset.timelineScrollViewport = "";
48
+ Object.defineProperties(viewport, {
49
+ clientWidth: { configurable: true, value: 500 },
50
+ clientHeight: { configurable: true, value: 300 },
51
+ scrollWidth: { configurable: true, value: 2_000 },
52
+ scrollHeight: { configurable: true, value: 2_000 },
53
+ });
54
+ viewport.getBoundingClientRect = () => new DOMRect(0, 0, 1_000, 500);
55
+ Object.assign(viewport, {
56
+ setPointerCapture: vi.fn(),
57
+ hasPointerCapture: vi.fn(() => true),
58
+ releasePointerCapture: vi.fn(),
59
+ });
60
+ document.body.appendChild(viewport);
61
+ const root = createRoot(viewport);
62
+ roots.push(root);
63
+ act(() => {
64
+ root.render(
65
+ <BeatStrip
66
+ beatTimes={[1, 3]}
67
+ beatStrengths={[0.5, 0.8]}
68
+ pps={100}
69
+ renderTimeRange={renderTimeRange}
70
+ />,
71
+ );
72
+ });
73
+ return { root, viewport };
74
+ }
75
+
76
+ function firstBeat(): HTMLDivElement {
77
+ const beat = document.querySelector<HTMLDivElement>(
78
+ '[title="Drag to move · double-click to delete"]',
79
+ );
80
+ if (!beat) throw new Error("Expected a beat handle");
81
+ return beat;
82
+ }
83
+
84
+ function startBeatDrag(clientX = 100, pointerId = 1): void {
85
+ act(() => {
86
+ firstBeat().dispatchEvent(
87
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX, clientY: 100, pointerId }),
88
+ );
89
+ });
90
+ expect(usePlayerStore.getState().beatDragging).toBe(true);
91
+ }
92
+
93
+ function releaseBeatDrag(clientX = 140, pointerId = 1): void {
94
+ act(() => {
95
+ window.dispatchEvent(
96
+ pointerEvent("pointerup", { bubbles: true, clientX, clientY: 100, pointerId }),
97
+ );
98
+ });
99
+ }
100
+
101
+ function expectCancelledBeatDrag(): void {
102
+ expect(commitBeatEditsSpy).not.toHaveBeenCalled();
103
+ expect(usePlayerStore.getState().beatDragging).toBe(false);
104
+ }
105
+
106
+ function expectCommittedBeatAt(time: number): void {
107
+ expect(commitBeatEditsSpy).toHaveBeenCalledExactlyOnceWith(expect.anything(), "move beat");
108
+ expect(usePlayerStore.getState().beatEdits?.added[0]?.time).toBe(time);
109
+ }
110
+
111
+ beforeEach(() => {
112
+ commitBeatEditsSpy.mockClear();
113
+ requestSeek.mockReset();
114
+ usePlayerStore.setState({
115
+ timelineSessionEpoch: 1,
116
+ timelineProjectId: "project-a",
117
+ beatDragging: false,
118
+ requestSeek,
119
+ elements: [MUSIC_ELEMENT],
120
+ beatAnalysis: BEAT_ANALYSIS,
121
+ beatEdits: null,
122
+ commitBeatEdits: commitBeatEditsSpy,
123
+ });
124
+ });
125
+
126
+ afterEach(() => {
127
+ act(() => window.dispatchEvent(new Event("blur")));
128
+ for (const root of roots.splice(0)) act(() => root.unmount());
129
+ document.body.innerHTML = "";
130
+ vi.unstubAllGlobals();
131
+ });
132
+
133
+ describe("BeatStrip gesture ownership", () => {
134
+ it("commits once after its source row unmounts", () => {
135
+ const { root } = mountBeatStrip();
136
+ startBeatDrag();
137
+ act(() => root.render(null));
138
+ expect(usePlayerStore.getState().beatDragging).toBe(true);
139
+
140
+ act(() => {
141
+ window.dispatchEvent(
142
+ pointerEvent("pointermove", {
143
+ bubbles: true,
144
+ clientX: 140,
145
+ clientY: 100,
146
+ pointerId: 1,
147
+ }),
148
+ );
149
+ window.dispatchEvent(
150
+ pointerEvent("pointerup", {
151
+ bubbles: true,
152
+ clientX: 140,
153
+ clientY: 100,
154
+ pointerId: 1,
155
+ }),
156
+ );
157
+ window.dispatchEvent(
158
+ pointerEvent("pointerup", {
159
+ bubbles: true,
160
+ clientX: 160,
161
+ clientY: 100,
162
+ pointerId: 1,
163
+ }),
164
+ );
165
+ });
166
+
167
+ expectCommittedBeatAt(1.4);
168
+ expect(usePlayerStore.getState().beatDragging).toBe(false);
169
+ });
170
+
171
+ it("includes viewport scrolling in the final beat time", () => {
172
+ const { viewport } = mountBeatStrip();
173
+ startBeatDrag(100);
174
+ viewport.scrollLeft = 50;
175
+
176
+ releaseBeatDrag(110);
177
+ expectCommittedBeatAt(1.6);
178
+ });
179
+
180
+ it("cancels without mutation on pointer cancel, Escape, and project switch", () => {
181
+ mountBeatStrip();
182
+ startBeatDrag();
183
+ act(() => {
184
+ window.dispatchEvent(pointerEvent("pointercancel", { pointerId: 1 }));
185
+ window.dispatchEvent(pointerEvent("pointerup", { clientX: 140, clientY: 100, pointerId: 1 }));
186
+ });
187
+
188
+ startBeatDrag();
189
+ act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
190
+
191
+ startBeatDrag();
192
+ act(() => usePlayerStore.getState().beginTimelineSession("project-b"));
193
+ releaseBeatDrag();
194
+ expectCancelledBeatDrag();
195
+ });
196
+
197
+ it("cancels without mutation when the captured music source disappears", () => {
198
+ mountBeatStrip();
199
+ startBeatDrag();
200
+ act(() => usePlayerStore.setState({ elements: [] }));
201
+ releaseBeatDrag();
202
+ expectCancelledBeatDrag();
203
+ });
204
+
205
+ it("cancels when the captured beat disappears during the gesture", () => {
206
+ mountBeatStrip();
207
+ startBeatDrag();
208
+ act(() => {
209
+ usePlayerStore.setState({
210
+ beatAnalysis: { ...BEAT_ANALYSIS, beatTimes: [3], beatStrengths: [0.8] },
211
+ });
212
+ });
213
+ releaseBeatDrag();
214
+ expectCancelledBeatDrag();
215
+ });
216
+
217
+ it("releases the actor when the owning timeline viewport unmounts", async () => {
218
+ const { viewport } = mountBeatStrip();
219
+ startBeatDrag();
220
+
221
+ await act(async () => {
222
+ viewport.remove();
223
+ await Promise.resolve();
224
+ });
225
+ releaseBeatDrag();
226
+ expectCancelledBeatDrag();
227
+ });
228
+
229
+ it("ignores unrelated pointers and keeps the active beat rendered outside the time window", () => {
230
+ const { root } = mountBeatStrip();
231
+ startBeatDrag();
232
+ act(() => {
233
+ window.dispatchEvent(
234
+ pointerEvent("pointermove", {
235
+ bubbles: true,
236
+ clientX: 150,
237
+ clientY: 100,
238
+ pointerId: 2,
239
+ }),
240
+ );
241
+ window.dispatchEvent(
242
+ pointerEvent("pointerup", {
243
+ bubbles: true,
244
+ clientX: 150,
245
+ clientY: 100,
246
+ pointerId: 2,
247
+ }),
248
+ );
249
+ root.render(
250
+ <BeatStrip
251
+ beatTimes={[1, 3]}
252
+ beatStrengths={[0.5, 0.8]}
253
+ pps={100}
254
+ renderTimeRange={{ start: 2.5, end: 3.5 }}
255
+ />,
256
+ );
257
+ });
258
+
259
+ expect(
260
+ document.querySelectorAll('[title="Drag to move · double-click to delete"]'),
261
+ ).toHaveLength(2);
262
+ expect(commitBeatEditsSpy).not.toHaveBeenCalled();
263
+
264
+ act(() => {
265
+ window.dispatchEvent(
266
+ pointerEvent("pointerup", {
267
+ bubbles: true,
268
+ clientX: 150,
269
+ clientY: 100,
270
+ pointerId: 1,
271
+ }),
272
+ );
273
+ });
274
+ expect(commitBeatEditsSpy).toHaveBeenCalledOnce();
275
+ });
276
+
277
+ it("keeps the dragged beat pinned by time when another beat is inserted before it", () => {
278
+ const { root } = mountBeatStrip();
279
+ startBeatDrag();
280
+ act(() => {
281
+ window.dispatchEvent(
282
+ pointerEvent("pointermove", {
283
+ bubbles: true,
284
+ clientX: 140,
285
+ clientY: 100,
286
+ pointerId: 1,
287
+ }),
288
+ );
289
+ usePlayerStore.setState({
290
+ beatAnalysis: {
291
+ ...BEAT_ANALYSIS,
292
+ beatTimes: [0.5, 1, 3],
293
+ beatStrengths: [0.3, 0.5, 0.8],
294
+ },
295
+ });
296
+ root.render(<BeatStrip beatTimes={[0.5, 1, 3]} beatStrengths={[0.3, 0.5, 0.8]} pps={100} />);
297
+ });
298
+
299
+ const lefts = Array.from(
300
+ document.querySelectorAll<HTMLDivElement>('[title="Drag to move · double-click to delete"]'),
301
+ (beat) => beat.style.left,
302
+ );
303
+ expect(lefts).toContain("134px");
304
+
305
+ releaseBeatDrag(140);
306
+ expectCommittedBeatAt(1.4);
307
+ });
308
+
309
+ it("keeps the first pointer in control when a second touch starts", () => {
310
+ mountBeatStrip();
311
+ startBeatDrag();
312
+ const beats = document.querySelectorAll<HTMLDivElement>(
313
+ '[title="Drag to move · double-click to delete"]',
314
+ );
315
+ act(() => {
316
+ beats[1]?.dispatchEvent(
317
+ pointerEvent("pointerdown", {
318
+ bubbles: true,
319
+ button: 0,
320
+ clientX: 300,
321
+ clientY: 100,
322
+ pointerId: 2,
323
+ }),
324
+ );
325
+ });
326
+
327
+ releaseBeatDrag(140, 1);
328
+ expectCommittedBeatAt(1.4);
329
+ });
330
+
331
+ it("lets Escape bubble before cancelling the active drag", () => {
332
+ mountBeatStrip();
333
+ startBeatDrag();
334
+ const sawEscape = vi.fn();
335
+ document.addEventListener("keydown", sawEscape, { once: true });
336
+
337
+ act(() => {
338
+ firstBeat().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
339
+ });
340
+
341
+ expect(sawEscape).toHaveBeenCalledOnce();
342
+ expectCancelledBeatDrag();
343
+ });
344
+
345
+ it("does not autoscroll or mutate below the drag threshold", () => {
346
+ const requestAnimationFrame = vi.fn(() => 1);
347
+ vi.stubGlobal("requestAnimationFrame", requestAnimationFrame);
348
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
349
+ mountBeatStrip();
350
+ startBeatDrag(990);
351
+
352
+ act(() => {
353
+ window.dispatchEvent(
354
+ pointerEvent("pointermove", {
355
+ bubbles: true,
356
+ clientX: 991,
357
+ clientY: 100,
358
+ pointerId: 1,
359
+ }),
360
+ );
361
+ window.dispatchEvent(
362
+ pointerEvent("pointerup", {
363
+ bubbles: true,
364
+ clientX: 991,
365
+ clientY: 100,
366
+ pointerId: 1,
367
+ }),
368
+ );
369
+ });
370
+
371
+ expect(requestAnimationFrame).not.toHaveBeenCalled();
372
+ expect(commitBeatEditsSpy).not.toHaveBeenCalled();
373
+ });
374
+ });