@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
@@ -1,12 +1,285 @@
1
- import { memo, useRef, useState } from "react";
2
- import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions";
1
+ import { memo, useSyncExternalStore } from "react";
2
+ import {
3
+ deleteBeatAtCompositionTime,
4
+ moveBeatCompositionTime,
5
+ remapBeatAnalysisToComposition,
6
+ } from "../../utils/beatEditActions";
3
7
  import { usePlayerStore } from "../store/playerStore";
4
8
  import { CLIP_Y, getTimelineBeatEntries } from "./timelineLayout";
5
9
  import type { TimelineTimeRange } from "../lib/timelineClipIndex";
10
+ import {
11
+ applyTimelineAutoScrollStep,
12
+ resolveTimelineAutoScrollLoopAction,
13
+ } from "./timelineEditing";
14
+ import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
6
15
 
7
16
  export const BEAT_BAND_H = 14; // dark band height at top of track
8
17
  const BEAT_HIT_W = 12; // grab width per beat (px)
9
18
 
19
+ interface BeatDragActor {
20
+ readonly pointerId: number;
21
+ readonly startX: number;
22
+ readonly clientX: number;
23
+ readonly clientY: number;
24
+ readonly originalTime: number;
25
+ readonly pixelsPerSecond: number;
26
+ readonly startScrollLeft: number;
27
+ readonly dx: number;
28
+ readonly started: boolean;
29
+ readonly sessionEpoch: number;
30
+ readonly projectId: string | null;
31
+ readonly musicElement: NonNullable<ReturnType<typeof getTimelineElementIndexes>["musicElement"]>;
32
+ readonly musicKey: string;
33
+ readonly musicSrc: string;
34
+ readonly scroll: HTMLElement;
35
+ }
36
+
37
+ let beatDragActor: BeatDragActor | null = null;
38
+ let beatDragRaf = 0;
39
+ let unsubscribeBeatDragSession: (() => void) | null = null;
40
+ let beatDragViewportObserver: MutationObserver | null = null;
41
+ const beatDragSubscribers = new Set<() => void>();
42
+
43
+ function publishBeatDrag(next: BeatDragActor | null): void {
44
+ beatDragActor = next;
45
+ for (const subscriber of beatDragSubscribers) subscriber();
46
+ }
47
+
48
+ function subscribeBeatDrag(subscriber: () => void): () => void {
49
+ beatDragSubscribers.add(subscriber);
50
+ return () => beatDragSubscribers.delete(subscriber);
51
+ }
52
+
53
+ function getBeatDragSnapshot(): BeatDragActor | null {
54
+ return beatDragActor;
55
+ }
56
+
57
+ function releaseBeatDragResources(actor: BeatDragActor): void {
58
+ if (beatDragRaf !== 0) {
59
+ cancelAnimationFrame(beatDragRaf);
60
+ beatDragRaf = 0;
61
+ }
62
+ unsubscribeBeatDragSession?.();
63
+ unsubscribeBeatDragSession = null;
64
+ beatDragViewportObserver?.disconnect();
65
+ beatDragViewportObserver = null;
66
+ window.removeEventListener("pointermove", handleBeatDragPointerMove);
67
+ window.removeEventListener("pointerup", handleBeatDragPointerUp);
68
+ window.removeEventListener("pointercancel", handleBeatDragPointerCancel);
69
+ window.removeEventListener("lostpointercapture", handleBeatDragPointerCancel);
70
+ window.removeEventListener("keydown", handleBeatDragKeyDown);
71
+ window.removeEventListener("blur", cancelBeatDrag);
72
+ try {
73
+ if (actor.scroll.hasPointerCapture?.(actor.pointerId)) {
74
+ actor.scroll.releasePointerCapture(actor.pointerId);
75
+ }
76
+ } catch {
77
+ // Window listeners retain terminal ownership when pointer capture is unavailable.
78
+ }
79
+ usePlayerStore.getState().setBeatDragging(false);
80
+ }
81
+
82
+ /** Claiming clears the shared actor before cleanup, so later terminal events are no-ops. */
83
+ function claimBeatDrag(pointerId?: number): BeatDragActor | null {
84
+ const actor = beatDragActor;
85
+ if (!actor || (pointerId !== undefined && actor.pointerId !== pointerId)) return null;
86
+ publishBeatDrag(null);
87
+ releaseBeatDragResources(actor);
88
+ return actor;
89
+ }
90
+
91
+ function cancelBeatDrag(): void {
92
+ claimBeatDrag();
93
+ }
94
+
95
+ function beatDragTime(actor: BeatDragActor): number {
96
+ return Math.max(0, actor.originalTime + actor.dx / actor.pixelsPerSecond);
97
+ }
98
+
99
+ function isBeatDragSourceCurrent(
100
+ actor: BeatDragActor,
101
+ state: ReturnType<typeof usePlayerStore.getState>,
102
+ ): boolean {
103
+ const currentMusic = getTimelineElementIndexes(state.elements).musicElement;
104
+ if (
105
+ !actor.scroll.isConnected ||
106
+ state.timelineSessionEpoch !== actor.sessionEpoch ||
107
+ state.timelineProjectId !== actor.projectId ||
108
+ currentMusic !== actor.musicElement ||
109
+ (currentMusic.key ?? currentMusic.id) !== actor.musicKey ||
110
+ currentMusic.src !== actor.musicSrc
111
+ ) {
112
+ return false;
113
+ }
114
+ const analysis = remapBeatAnalysisToComposition(
115
+ state.beatAnalysis,
116
+ currentMusic,
117
+ state.beatEdits,
118
+ );
119
+ return analysis?.beatTimes.some((time) => Math.abs(time - actor.originalTime) < 1e-3) === true;
120
+ }
121
+
122
+ function updateBeatDrag(clientX: number, clientY: number): BeatDragActor | null {
123
+ const actor = beatDragActor;
124
+ if (!actor) return null;
125
+ const pointerDx = clientX - actor.startX;
126
+ const next = {
127
+ ...actor,
128
+ clientX,
129
+ clientY,
130
+ dx: pointerDx + actor.scroll.scrollLeft - actor.startScrollLeft,
131
+ started: actor.started || Math.abs(pointerDx) > 2,
132
+ };
133
+ publishBeatDrag(next);
134
+ usePlayerStore.getState().requestSeek(beatDragTime(next));
135
+ return next;
136
+ }
137
+
138
+ function stepBeatDragAutoScroll(): void {
139
+ beatDragRaf = 0;
140
+ const actor = beatDragActor;
141
+ if (!actor?.started) return;
142
+ if (!applyTimelineAutoScrollStep(actor.scroll, actor.clientX, actor.clientY)) return;
143
+ updateBeatDrag(actor.clientX, actor.clientY);
144
+ beatDragRaf = requestAnimationFrame(stepBeatDragAutoScroll);
145
+ }
146
+
147
+ function syncBeatDragAutoScroll(actor: BeatDragActor): void {
148
+ const action = resolveTimelineAutoScrollLoopAction(
149
+ actor.scroll,
150
+ actor.clientX,
151
+ actor.clientY,
152
+ beatDragRaf !== 0,
153
+ );
154
+ if (action === "start" && actor.started) {
155
+ beatDragRaf = requestAnimationFrame(stepBeatDragAutoScroll);
156
+ } else if (action === "stop" && beatDragRaf !== 0) {
157
+ cancelAnimationFrame(beatDragRaf);
158
+ beatDragRaf = 0;
159
+ }
160
+ }
161
+
162
+ function handleBeatDragPointerMove(event: PointerEvent): void {
163
+ if (event.pointerId !== beatDragActor?.pointerId) return;
164
+ event.preventDefault();
165
+ const actor = updateBeatDrag(event.clientX, event.clientY);
166
+ if (actor) syncBeatDragAutoScroll(actor);
167
+ }
168
+
169
+ function handleBeatDragPointerUp(event: PointerEvent): void {
170
+ if (event.pointerId !== beatDragActor?.pointerId) return;
171
+ const latest = updateBeatDrag(event.clientX, event.clientY);
172
+ const actor = claimBeatDrag(event.pointerId);
173
+ if (!actor || !latest?.started) return;
174
+ const store = usePlayerStore.getState();
175
+ if (!isBeatDragSourceCurrent(actor, store)) return;
176
+ const newTime = beatDragTime(latest);
177
+ moveBeatCompositionTime(actor.originalTime, newTime);
178
+ store.requestSeek(newTime);
179
+ }
180
+
181
+ function handleBeatDragPointerCancel(event: PointerEvent): void {
182
+ claimBeatDrag(event.pointerId);
183
+ }
184
+
185
+ function handleBeatDragKeyDown(event: KeyboardEvent): void {
186
+ if (event.key !== "Escape" || !beatDragActor) return;
187
+ event.preventDefault();
188
+ cancelBeatDrag();
189
+ }
190
+
191
+ function resolveBeatDragViewport(
192
+ event: React.PointerEvent<HTMLDivElement>,
193
+ pixelsPerSecond: number,
194
+ ): HTMLElement | null {
195
+ if (event.button !== 0 || !(pixelsPerSecond > 0)) return null;
196
+ return event.currentTarget.closest<HTMLElement>("[data-timeline-scroll-viewport]");
197
+ }
198
+
199
+ function createBeatDragActor(
200
+ event: React.PointerEvent<HTMLDivElement>,
201
+ originalTime: number,
202
+ pixelsPerSecond: number,
203
+ scroll: HTMLElement,
204
+ ): BeatDragActor | null {
205
+ const store = usePlayerStore.getState();
206
+ const musicElement = getTimelineElementIndexes(store.elements).musicElement;
207
+ if (!musicElement?.src) return null;
208
+ const actor: BeatDragActor = {
209
+ pointerId: event.pointerId,
210
+ startX: event.clientX,
211
+ clientX: event.clientX,
212
+ clientY: event.clientY,
213
+ originalTime,
214
+ pixelsPerSecond,
215
+ startScrollLeft: scroll.scrollLeft,
216
+ dx: 0,
217
+ started: false,
218
+ sessionEpoch: store.timelineSessionEpoch,
219
+ projectId: store.timelineProjectId,
220
+ musicElement,
221
+ musicKey: musicElement.key ?? musicElement.id,
222
+ musicSrc: musicElement.src,
223
+ scroll,
224
+ };
225
+ return isBeatDragSourceCurrent(actor, store) ? actor : null;
226
+ }
227
+
228
+ function activateBeatDrag(actor: BeatDragActor): void {
229
+ publishBeatDrag(actor);
230
+ window.addEventListener("pointermove", handleBeatDragPointerMove);
231
+ window.addEventListener("pointerup", handleBeatDragPointerUp);
232
+ window.addEventListener("pointercancel", handleBeatDragPointerCancel);
233
+ window.addEventListener("lostpointercapture", handleBeatDragPointerCancel);
234
+ window.addEventListener("keydown", handleBeatDragKeyDown);
235
+ window.addEventListener("blur", cancelBeatDrag);
236
+ unsubscribeBeatDragSession = usePlayerStore.subscribe((state, previous) => {
237
+ // Unlike gestures that can validate only at pointerup, beat drag must stop
238
+ // immediately when its music source disappears or changes mid-stream. This
239
+ // subscription owns that interruption; the identity guard keeps requestSeek's
240
+ // per-frame store writes allocation-free unless a source input changed.
241
+ if (
242
+ state.timelineSessionEpoch === previous.timelineSessionEpoch &&
243
+ state.timelineProjectId === previous.timelineProjectId &&
244
+ state.elements === previous.elements &&
245
+ state.beatAnalysis === previous.beatAnalysis &&
246
+ state.beatEdits === previous.beatEdits
247
+ ) {
248
+ return;
249
+ }
250
+ if (!isBeatDragSourceCurrent(actor, state)) cancelBeatDrag();
251
+ });
252
+ const viewportParent = actor.scroll.parentNode;
253
+ if (viewportParent) {
254
+ beatDragViewportObserver = new MutationObserver(() => {
255
+ if (!actor.scroll.isConnected) cancelBeatDrag();
256
+ });
257
+ beatDragViewportObserver.observe(viewportParent, { childList: true });
258
+ }
259
+ try {
260
+ actor.scroll.setPointerCapture?.(actor.pointerId);
261
+ } catch {
262
+ // Window listeners retain terminal ownership when pointer capture is unavailable.
263
+ }
264
+ const store = usePlayerStore.getState();
265
+ store.setBeatDragging(true);
266
+ store.requestSeek(Math.max(0, actor.originalTime));
267
+ }
268
+
269
+ function beginBeatDrag(
270
+ event: React.PointerEvent<HTMLDivElement>,
271
+ originalTime: number,
272
+ pixelsPerSecond: number,
273
+ ): void {
274
+ // One pointer owns the actor until a terminal event claims it. A second touch
275
+ // must not silently discard the first gesture and make its release a no-op.
276
+ if (beatDragActor) return;
277
+ const scroll = resolveBeatDragViewport(event, pixelsPerSecond);
278
+ if (!scroll) return;
279
+ const actor = createBeatDragActor(event, originalTime, pixelsPerSecond, scroll);
280
+ if (actor) activateBeatDrag(actor);
281
+ }
282
+
10
283
  /** Hide both layers when beats are packed tighter than this (px) — too dense to read. */
11
284
  function beatsTooDense(beatTimes: number[], pps: number): boolean {
12
285
  if (beatTimes.length < 2) return true;
@@ -96,17 +369,31 @@ export const BeatStrip = memo(function BeatStrip({
96
369
  pps: number;
97
370
  renderTimeRange?: TimelineTimeRange;
98
371
  }) {
99
- // Active drag: which beat and how far (px) it's been dragged.
100
- const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null);
101
- const dragRef = useRef<{ index: number; startX: number; origTime: number } | null>(null);
372
+ const activeActor = useSyncExternalStore(
373
+ subscribeBeatDrag,
374
+ getBeatDragSnapshot,
375
+ getBeatDragSnapshot,
376
+ );
377
+ const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
378
+ const projectId = usePlayerStore((state) => state.timelineProjectId);
102
379
 
103
380
  if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
381
+ const activeBeatIndex = activeActor
382
+ ? beatTimes.findIndex((time) => Math.abs(time - activeActor.originalTime) < 1e-3)
383
+ : -1;
384
+ const drag =
385
+ activeActor &&
386
+ activeActor.sessionEpoch === sessionEpoch &&
387
+ activeActor.projectId === projectId &&
388
+ activeBeatIndex >= 0
389
+ ? activeActor
390
+ : null;
104
391
  const cy = BEAT_BAND_H / 2;
105
392
  const beatEntries = getTimelineBeatEntries(
106
393
  beatTimes,
107
394
  beatStrengths,
108
395
  renderTimeRange,
109
- drag ? new Set([drag.index]) : undefined,
396
+ drag ? new Set([activeBeatIndex]) : undefined,
110
397
  );
111
398
 
112
399
  return (
@@ -119,7 +406,7 @@ export const BeatStrip = memo(function BeatStrip({
119
406
  const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
120
407
  const r = 1.5 + strength * 2.5;
121
408
  const opacity = 0.25 + strength * 0.75;
122
- const dxPx = drag?.index === i ? drag.dx : 0;
409
+ const dxPx = drag && activeBeatIndex === i ? drag.dx : 0;
123
410
  const x = t * pps + dxPx;
124
411
  return (
125
412
  <div
@@ -141,36 +428,7 @@ export const BeatStrip = memo(function BeatStrip({
141
428
  // selection (which otherwise "selects" the whole panel mid-drag).
142
429
  e.preventDefault();
143
430
  e.stopPropagation();
144
- e.currentTarget.setPointerCapture(e.pointerId);
145
- dragRef.current = { index: i, startX: e.clientX, origTime: t };
146
- setDrag({ index: i, dx: 0 });
147
- usePlayerStore.getState().setBeatDragging(true); // hide the playhead guideline
148
- usePlayerStore.getState().requestSeek(Math.max(0, t)); // scrub audio at beat
149
- }}
150
- onPointerMove={(e) => {
151
- const d = dragRef.current;
152
- if (!d || d.index !== i) return;
153
- e.preventDefault();
154
- const dx = e.clientX - d.startX;
155
- setDrag({ index: i, dx });
156
- // Scrub the audio (and move the playhead) to follow the dragged beat.
157
- usePlayerStore.getState().requestSeek(Math.max(0, d.origTime + dx / pps));
158
- }}
159
- onPointerUp={(e) => {
160
- const d = dragRef.current;
161
- dragRef.current = null;
162
- setDrag(null);
163
- usePlayerStore.getState().setBeatDragging(false);
164
- if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
165
- e.currentTarget.releasePointerCapture(e.pointerId);
166
- }
167
- if (!d || d.index !== i) return;
168
- const dx = e.clientX - d.startX;
169
- if (Math.abs(dx) > 2) {
170
- const newTime = Math.max(0, d.origTime + dx / pps);
171
- moveBeatCompositionTime(d.origTime, newTime);
172
- usePlayerStore.getState().requestSeek(newTime); // park scrubber at new beat
173
- }
431
+ beginBeatDrag(e, t, pps);
174
432
  }}
175
433
  onDoubleClick={(e) => {
176
434
  e.stopPropagation();
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
- import { describe, expect, it, vi } from "vitest";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { TimelineElement } from "../store/playerStore";
6
6
  import {
7
7
  KeyframeDiamondContextMenu,
@@ -10,6 +10,10 @@ import {
10
10
 
11
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
12
 
13
+ afterEach(() => {
14
+ document.body.innerHTML = "";
15
+ });
16
+
13
17
  const element = { id: "box", start: 0, duration: 2, track: 0 } as unknown as TimelineElement;
14
18
 
15
19
  const state: KeyframeDiamondContextMenuState = {
@@ -87,4 +91,15 @@ describe("KeyframeDiamondContextMenu", () => {
87
91
  act(() => root.unmount());
88
92
  host.remove();
89
93
  });
94
+
95
+ // The layer-wide delete takes every keyframed tween. Opened from a diamond it
96
+ // has to stay on that diamond's own lane, or right-clicking the opacity lane
97
+ // silently clears position too.
98
+ it("deletes all keyframes from the animation that opened the menu", () => {
99
+ const onDeleteAll = vi.fn();
100
+
101
+ clickMenuItem("Delete All Keyframes", { onDeleteAll });
102
+
103
+ expect(onDeleteAll).toHaveBeenCalledExactlyOnceWith(element, "box-to-1-position");
104
+ });
90
105
  });
@@ -1,4 +1,3 @@
1
- import { memo } from "react";
2
1
  import { createPortal } from "react-dom";
3
2
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
3
  import type { TimelineElement } from "../store/playerStore";
@@ -7,6 +6,8 @@ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
7
6
  export interface KeyframeDiamondContextMenuState {
8
7
  x: number;
9
8
  y: number;
9
+ /** Timeline project session that created this portaled target. */
10
+ sessionEpoch?: number;
10
11
  element: TimelineElement;
11
12
  elementId: string;
12
13
  percentage: number;
@@ -23,12 +24,12 @@ interface KeyframeDiamondContextMenuProps {
23
24
  * floor in removeMotionPathPointInScript): an entry that silently no-ops is
24
25
  * worse than no entry. */
25
26
  onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
26
- onDeleteAll: (element: TimelineElement) => void;
27
+ onDeleteAll: (element: TimelineElement, animationId?: string) => void;
27
28
  /** Retime the keyframe to the current playhead, preserving its value + ease. */
28
29
  onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
29
30
  }
30
31
 
31
- export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
32
+ export function KeyframeDiamondContextMenu({
32
33
  state,
33
34
  onClose,
34
35
  onDelete,
@@ -93,7 +94,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
93
94
  type="button"
94
95
  className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
95
96
  onClick={() => {
96
- onDeleteAll(state.element);
97
+ onDeleteAll(state.element, state.animationId);
97
98
  onClose();
98
99
  }}
99
100
  >
@@ -102,4 +103,4 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
102
103
  </div>,
103
104
  document.body,
104
105
  );
105
- });
106
+ }
@@ -44,6 +44,8 @@ export function LayerDisclosureRow({
44
44
  >
45
45
  <button
46
46
  type="button"
47
+ // ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
48
+ tabIndex={-1}
47
49
  aria-expanded={isExpanded}
48
50
  aria-controls={lanesId}
49
51
  aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
@@ -244,6 +244,25 @@ describe("Timeline provider boundary", () => {
244
244
  expect(propertyLane.style.background).toBe("");
245
245
  expect(propertyLane.style.border).toBe("");
246
246
  expect(propertyLane.style.borderRadius).toBe("");
247
+ const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
248
+ const semanticRows = treegrid?.querySelectorAll<HTMLElement>('[role="row"]') ?? [];
249
+ expect(treegrid?.getAttribute("aria-rowcount")).toBe("3");
250
+ expect([...semanticRows].map((row) => row.getAttribute("aria-rowindex"))).toEqual([
251
+ "1",
252
+ "2",
253
+ "3",
254
+ ]);
255
+ expect(semanticRows[0]?.getAttribute("aria-level")).toBe("1");
256
+ expect(semanticRows[0]?.getAttribute("aria-expanded")).toBe("true");
257
+ expect(semanticRows[1]?.getAttribute("aria-level")).toBe("2");
258
+ expect(semanticRows[1]?.textContent).toContain("position");
259
+ expect(semanticRows[1]?.querySelector('[role="rowheader"]')?.getAttribute("aria-owns")).toBe(
260
+ headerLane.id,
261
+ );
262
+ expect(semanticRows[1]?.querySelector('[role="gridcell"]')?.getAttribute("aria-owns")).toBe(
263
+ propertyLane.id,
264
+ );
265
+ expect(semanticRows[2]?.hasAttribute("aria-expanded")).toBe(false);
247
266
  expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`);
248
267
  expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`);
249
268
  expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`);
@@ -295,17 +314,16 @@ describe("Timeline provider boundary", () => {
295
314
  const root = createRoot(host);
296
315
  act(() => root.render(React.createElement(Timeline)));
297
316
 
298
- const list = host.querySelector<HTMLElement>('[role="list"]');
299
- const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
317
+ const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
318
+ const rows = treegrid?.querySelectorAll('[role="row"]') ?? [];
300
319
  expect(rows).toHaveLength(12);
301
- expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
302
- expect(rows[0]?.getAttribute("aria-setsize")).toBe("12");
303
- expect(rows[11]?.getAttribute("aria-posinset")).toBe("12");
320
+ expect(treegrid?.getAttribute("aria-rowcount")).toBe("12");
321
+ expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1");
322
+ expect(rows[11]?.getAttribute("aria-rowindex")).toBe("12");
304
323
 
305
324
  act(() => root.unmount());
306
325
  });
307
326
 
308
- // fallow-ignore-next-line code-duplication
309
327
  it("renders the gutter without legacy icons or hue dots", () => {
310
328
  const { host, root } = renderBasicTimeline();
311
329
 
@@ -404,8 +422,8 @@ describe("Timeline provider boundary", () => {
404
422
  );
405
423
  });
406
424
 
407
- const viewport = host.querySelector('[aria-label="Timeline"]')?.firstElementChild;
408
- expect(viewport).toBeInstanceOf(HTMLElement);
425
+ const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
426
+ expect(viewport).not.toBeNull();
409
427
  act(() => {
410
428
  viewport?.dispatchEvent(
411
429
  new MouseEvent("pointerdown", {