@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
@@ -12,7 +12,7 @@ import { TimelineEmptyState } from "./TimelineEmptyState";
12
12
  import { TimelineCanvas } from "./TimelineCanvas";
13
13
  import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
14
14
  import { useTimelineClipDrag } from "./useTimelineClipDrag";
15
- import { TimelineOverlays } from "./TimelineOverlays";
15
+ import { TimelineOverlays, type ClipContextMenuState } from "./TimelineOverlays";
16
16
  import { useTimelineEditPinning } from "./useTimelineEditPinning";
17
17
  import { useTimelineStackingSync } from "./useTimelineStackingSync";
18
18
  import { useTimelineGeometry } from "./useTimelineGeometry";
@@ -29,7 +29,6 @@ import {
29
29
  import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
30
30
  import { useTrackGapMenu } from "./useTrackGapMenu";
31
31
  import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
32
- import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
33
32
  import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
34
33
  import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
35
34
  import {
@@ -42,9 +41,10 @@ import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
42
41
  import { useTimelineTicks } from "./useTimelineTicks";
43
42
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
44
43
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
45
- import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
46
44
  import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
47
45
  import { useTimelineActiveClips } from "./useTimelineActiveClips";
46
+ import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
47
+ import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
48
48
 
49
49
  export {
50
50
  shouldAutoScrollTimeline,
@@ -106,11 +106,7 @@ export const Timeline = memo(function Timeline({
106
106
  onSplitElement: onSplitElementOverride,
107
107
  });
108
108
  const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
109
- const playbackContext = useStudioPlaybackContextOptional();
110
- const setRefreshKey = playbackContext?.setRefreshKey;
111
- const refreshAfterLaneMove = useCallback(() => {
112
- setRefreshKey?.((key) => key + 1);
113
- }, [setRefreshKey]);
109
+ const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
114
110
  useMusicBeatAnalysis();
115
111
  const rawElements = usePlayerStore((s) => s.elements);
116
112
  const expandedElements = useExpandedTimelineElements();
@@ -126,17 +122,10 @@ export const Timeline = memo(function Timeline({
126
122
  const timelineReady = usePlayerStore((s) => s.timelineReady);
127
123
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
128
124
  const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
129
- const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
130
125
  const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
131
126
  const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
132
- const hasKeyframedClips = useMemo(
133
- () => hasKeyframedTimelineClips(gsapAnimations),
134
- [gsapAnimations],
135
- );
136
- const labelMode = hasKeyframedClips;
137
- // Without the label column the pre-t=0 breathing room is still TRACKS_LEFT_PAD
138
- // (dropping it would jam clip 0 against the gutter on every non-keyframed
139
- // composition); in label mode the 232px label column already provides it.
127
+ const labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]);
128
+ // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
140
129
  const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
141
130
  const contentGutter = labelMode ? GUTTER : 0;
142
131
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
@@ -151,11 +140,7 @@ export const Timeline = memo(function Timeline({
151
140
  const shiftHeld = useTimelineShiftModifier();
152
141
  const [showPopover, setShowPopover] = useState(false);
153
142
  const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
154
- const [clipContextMenu, setClipContextMenu] = useState<{
155
- x: number;
156
- y: number;
157
- element: TimelineElement;
158
- } | null>(null);
143
+ const [clipContextMenu, setClipContextMenu] = useState<ClipContextMenuState | null>(null);
159
144
  const setContainerRef = useCallback((el: HTMLDivElement | null) => {
160
145
  containerRef.current = el;
161
146
  }, []);
@@ -185,7 +170,6 @@ export const Timeline = memo(function Timeline({
185
170
  const ppsRef = useRef(100);
186
171
  const durationRef = useRef(effectiveDuration);
187
172
  durationRef.current = effectiveDuration;
188
- // Declared before the fitPps derivation so the edit-pin wrappers can close over it.
189
173
  const fitPpsRef = useRef(100);
190
174
  const {
191
175
  pinZoomBeforeEdit,
@@ -236,7 +220,6 @@ export const Timeline = memo(function Timeline({
236
220
  setResizingClip,
237
221
  blockedClipRef,
238
222
  suppressClickRef,
239
- syncClipDragAutoScroll,
240
223
  } = useTimelineClipDrag({
241
224
  scrollRef,
242
225
  ppsRef,
@@ -253,22 +236,26 @@ export const Timeline = memo(function Timeline({
253
236
  readZIndex: zSyncEnabled ? readClipZIndex : undefined,
254
237
  onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined,
255
238
  refreshAfterLaneMove,
239
+ sessionEpoch,
256
240
  });
257
241
 
258
- const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
259
- useTimelineAssetDrop({
260
- scrollRef,
261
- ppsRef,
262
- durationRef,
263
- trackOrderRef,
264
- rowGeometryRef,
265
- contentOrigin,
266
- onFileDrop: pinnedOnFileDrop,
267
- onAssetDrop: pinnedOnAssetDrop,
268
- onBlockDrop: pinnedOnBlockDrop,
269
- onCompositionDrop: pinnedOnCompositionDrop,
270
- });
242
+ const assetDrop = useTimelineAssetDrop({
243
+ scrollRef,
244
+ ppsRef,
245
+ durationRef,
246
+ trackOrderRef,
247
+ rowGeometryRef,
248
+ contentOrigin,
249
+ onFileDrop: pinnedOnFileDrop,
250
+ onAssetDrop: pinnedOnAssetDrop,
251
+ onBlockDrop: pinnedOnBlockDrop,
252
+ onCompositionDrop: pinnedOnCompositionDrop,
253
+ sessionEpoch,
254
+ });
271
255
  const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
256
+ const resizingElementIds =
257
+ resizingClip?.groupPreview?.map((change) => change.key) ??
258
+ (resizingClip ? [getTimelineElementIdentity(resizingClip.element)] : undefined);
272
259
  const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
273
260
  totalClipCount: expandedElements.length,
274
261
  totalRowCount: displayLayout.displayTrackOrder.length,
@@ -280,22 +267,43 @@ export const Timeline = memo(function Timeline({
280
267
  expandedElements.length,
281
268
  displayLayout.totalH,
282
269
  ]);
283
- const rowWindow = useTimelineRowVirtualization({
270
+ const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
271
+ useTimelineGeometry({
272
+ viewportWidth: viewport.clientWidth,
273
+ effectiveDuration,
274
+ zoomMode,
275
+ manualZoomPercent,
276
+ ppsRef,
277
+ fitPpsRef,
278
+ draggedClip,
279
+ resizingClip,
280
+ expandedElements,
281
+ isDragging,
282
+ scrollRef,
283
+ lastScrollLeftRef,
284
+ contentOrigin,
285
+ });
286
+ const timelineFocus = useTimelineLogicalFocus({
284
287
  scrollRef,
288
+ tracks,
289
+ layout: displayLayout,
290
+ laneCounts,
291
+ selectedElementId,
292
+ selectedElementIds,
293
+ gsapAnimations,
294
+ elements: expandedElements,
295
+ pixelsPerSecond: pps,
296
+ contentOrigin,
297
+ allowHorizontal: zoomMode === "manual",
285
298
  viewport,
286
- rowGeometry: displayLayout.rowGeometry,
287
299
  sessionEpoch,
288
- elements: expandedElements,
289
- selectedElementId,
290
- revealElementId: clipRevealRequest?.elementId ?? null,
291
300
  draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
292
- resizingRowKey: resizingClip?.element.track,
301
+ resizingElementIds,
293
302
  clipContextMenuRowKey: clipContextMenu?.element.track,
294
303
  keyframeContextMenuRowKey: kfContextMenu?.element.track,
295
304
  lastScrollLeftRef,
296
305
  syncScrollViewport,
297
306
  });
298
- const { enabled: rowVirtualizationActive, virtualRows, focusedElementId } = rowWindow;
299
307
  const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
300
308
  const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
301
309
  const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
@@ -308,22 +316,7 @@ export const Timeline = memo(function Timeline({
308
316
  setKfContextMenu,
309
317
  toggleSelectedKeyframe,
310
318
  });
311
- const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
312
- useTimelineGeometry({
313
- viewportWidth: viewport.clientWidth,
314
- effectiveDuration,
315
- zoomMode,
316
- manualZoomPercent,
317
- ppsRef,
318
- fitPpsRef,
319
- draggedClip,
320
- resizingClip,
321
- expandedElements,
322
- isDragging,
323
- scrollRef,
324
- lastScrollLeftRef,
325
- contentOrigin,
326
- });
319
+
327
320
  const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
328
321
  tracks,
329
322
  viewport,
@@ -332,8 +325,8 @@ export const Timeline = memo(function Timeline({
332
325
  duration: displayDuration,
333
326
  selectedElementId: selectedElementId ?? undefined,
334
327
  draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
335
- resizingElementId: resizingClip ? getTimelineElementIdentity(resizingClip.element) : undefined,
336
- revealElementId: clipRevealRequest?.elementId,
328
+ resizingElementIds,
329
+ focusedElementId: timelineFocus.pinnedElementId,
337
330
  focusedEaseElementId: focusedEaseSegment?.elementId,
338
331
  clipContextMenuElementId: clipContextMenu
339
332
  ? getTimelineElementIdentity(clipContextMenu.element)
@@ -341,13 +334,6 @@ export const Timeline = memo(function Timeline({
341
334
  keyframeContextMenuElementId: kfContextMenu
342
335
  ? getTimelineElementIdentity(kfContextMenu.element)
343
336
  : undefined,
344
- focusedElementId,
345
- scrollRef,
346
- elements: expandedElements,
347
- rowGeometry: displayLayout.rowGeometry,
348
- allowHorizontalReveal: zoomMode === "manual",
349
- rowVirtualizationActive,
350
- sessionEpoch,
351
337
  });
352
338
  useTimelineActiveClips({
353
339
  scrollRef,
@@ -405,6 +391,7 @@ export const Timeline = memo(function Timeline({
405
391
  handlePointerDown,
406
392
  handlePointerMove,
407
393
  handlePointerUp,
394
+ handlePointerCancel,
408
395
  } = useTimelineRangeSelection({
409
396
  scrollRef,
410
397
  ppsRef,
@@ -417,10 +404,11 @@ export const Timeline = memo(function Timeline({
417
404
  isDragging,
418
405
  setShowPopover,
419
406
  elementsRef: expandedElementsRef,
420
- trackOrderRef,
407
+ clipIndex,
421
408
  rowGeometryRef,
422
409
  onSelectElement,
423
410
  contentOrigin,
411
+ sessionEpoch,
424
412
  });
425
413
  setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
426
414
 
@@ -432,7 +420,7 @@ export const Timeline = memo(function Timeline({
432
420
  displayDuration,
433
421
  pps,
434
422
  timeDisplayMode,
435
- rowVirtualizationActive ? renderTimeRange : undefined,
423
+ timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
436
424
  );
437
425
 
438
426
  const getPreviewElement = useCallback(
@@ -443,11 +431,11 @@ export const Timeline = memo(function Timeline({
443
431
  if (!timelineReady || expandedElements.length === 0) {
444
432
  return (
445
433
  <TimelineEmptyState
446
- isDragOver={isDragOver}
434
+ isDragOver={assetDrop.isDragOver}
447
435
  onFileDrop={!!onFileDrop}
448
- onDragOver={handleAssetDragOver}
449
- onDragLeave={() => clearDropPreview()}
450
- onDrop={handleAssetDrop}
436
+ onDragOver={assetDrop.handleAssetDragOver}
437
+ onDragLeave={assetDrop.handleAssetDragLeave}
438
+ onDrop={assetDrop.handleAssetDrop}
451
439
  />
452
440
  );
453
441
  }
@@ -457,7 +445,7 @@ export const Timeline = memo(function Timeline({
457
445
  ref={setContainerRef}
458
446
  aria-label="Timeline"
459
447
  data-timeline-element-count={expandedElements.length}
460
- className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
448
+ className={`relative border-t select-none h-full overflow-hidden ${assetDrop.isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
461
449
  onMouseMove={updateRazorGuide}
462
450
  onMouseLeave={clearRazorGuide}
463
451
  style={{
@@ -468,6 +456,7 @@ export const Timeline = memo(function Timeline({
468
456
  >
469
457
  <div
470
458
  ref={setScrollRef}
459
+ // Stable owner for gestures that must survive virtual row/clip unmounts.
471
460
  data-timeline-scroll-viewport
472
461
  data-timeline-auto-scroll-left-inset={labelMode ? LABEL_COL_W : 0}
473
462
  tabIndex={-1}
@@ -477,20 +466,20 @@ export const Timeline = memo(function Timeline({
477
466
  recordTimelineScroll(e.currentTarget);
478
467
  syncScrollViewport(e.currentTarget, true);
479
468
  }}
480
- {...rowWindow.timelineFocusProps}
481
- onDragOver={handleAssetDragOver}
482
- onDragLeave={() => clearDropPreview()}
483
- onDrop={handleAssetDrop}
469
+ {...timelineFocus.timelineFocusProps}
470
+ onDragOver={assetDrop.handleAssetDragOver}
471
+ onDragLeave={assetDrop.handleAssetDragLeave}
472
+ onDrop={assetDrop.handleAssetDrop}
484
473
  onPointerDown={(e) => {
485
- // Let interactive controls (keyframe nav/toggle, caret, inputs) handle
486
- // their own clicks — scrubbing here would preventDefault and eat them.
474
+ // Interactive controls own their clicks; scrubbing would preventDefault and eat them.
487
475
  if (e.target instanceof Element && e.target.closest("button, input, select, a")) return;
488
476
  if (splitAllAtPointer(e)) return;
489
477
  handlePointerDown(e);
490
478
  }}
491
479
  onPointerMove={handlePointerMove}
492
480
  onPointerUp={handlePointerUp}
493
- onLostPointerCapture={handlePointerUp}
481
+ onPointerCancel={handlePointerCancel}
482
+ onLostPointerCapture={handlePointerCancel}
494
483
  >
495
484
  <TimelineCanvas
496
485
  major={major}
@@ -509,8 +498,10 @@ export const Timeline = memo(function Timeline({
509
498
  displayTrackOrder={displayLayout.displayTrackOrder}
510
499
  rowHeights={displayLayout.displayRowHeights}
511
500
  rowGeometry={displayLayout.rowGeometry}
512
- virtualRows={virtualRows}
513
- rowsVirtualized={rowVirtualizationActive}
501
+ virtualRows={timelineFocus.virtualRows}
502
+ logicalRows={timelineFocus.logicalRows}
503
+ focusedTargetId={timelineFocus.focusedTargetId}
504
+ rowsVirtualized={timelineFocus.rowVirtualizationActive}
514
505
  clipIndex={clipIndex}
515
506
  renderTimeRange={renderTimeRange}
516
507
  pinnedClipIdentities={pinnedClipIdentities}
@@ -529,7 +520,9 @@ export const Timeline = memo(function Timeline({
529
520
  scrollRef={scrollRef}
530
521
  // Windowing drops content to mount a row cheaply; unvirtualized it is pure cost.
531
522
  renderClipContent={
532
- rowVirtualizationActive && viewport.isScrolling ? undefined : renderClipContent
523
+ timelineFocus.rowVirtualizationActive && viewport.isScrolling
524
+ ? undefined
525
+ : renderClipContent
533
526
  }
534
527
  renderClipOverlay={renderClipOverlay}
535
528
  playheadRef={playheadRef}
@@ -541,7 +534,6 @@ export const Timeline = memo(function Timeline({
541
534
  setResizingClip={setResizingClip}
542
535
  setDraggedClip={setDraggedClip}
543
536
  setSelectedElementId={setSelectedElementId}
544
- syncClipDragAutoScroll={syncClipDragAutoScroll}
545
537
  shiftClickClipRef={shiftClickClipRef}
546
538
  getPreviewElement={getPreviewElement}
547
539
  getTrackStyle={getTrackStyle}
@@ -561,7 +553,12 @@ export const Timeline = memo(function Timeline({
561
553
  setSelectedElementId(el.key ?? el.id);
562
554
  onSelectElement?.(el);
563
555
  dismissGapMenu();
564
- setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
556
+ setClipContextMenu({
557
+ x: e.clientX,
558
+ y: e.clientY,
559
+ element: el,
560
+ sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
561
+ });
565
562
  }}
566
563
  onContextMenuLane={(e, track, time) => {
567
564
  if (draggedClip?.started || resizingClip) return;
@@ -572,6 +569,8 @@ export const Timeline = memo(function Timeline({
572
569
  {activeTool === "razor" && razorGuideX !== null && <TimelineRazorGuide x={razorGuideX} />}
573
570
  </div>
574
571
  <TimelineOverlays
572
+ elements={expandedElements}
573
+ elementsRef={expandedElementsRef}
575
574
  theme={theme}
576
575
  showShortcutHint={showShortcutHint}
577
576
  showPopover={showPopover}
@@ -132,7 +132,7 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
132
132
 
133
133
  const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 2 }));
134
134
 
135
- const rows = host.querySelectorAll('[role="listitem"]');
135
+ const rows = host.querySelectorAll("[data-timeline-row]");
136
136
  expect(rows.length).toBeGreaterThan(0);
137
137
  expect(rows.length).toBeLessThanOrEqual(16);
138
138
 
@@ -201,16 +201,17 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
201
201
 
202
202
  await settleUntil(
203
203
  () =>
204
- (host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) >
205
- 0,
204
+ (host.querySelector('[role="treegrid"]')?.querySelectorAll('[role="row"]').length ?? 0) > 0,
206
205
  );
207
- const list = host.querySelector<HTMLElement>('[role="list"]');
208
- const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
206
+ const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
207
+ const rows = treegrid?.querySelectorAll('[role="row"]') ?? [];
209
208
  expect(rows.length).toBeGreaterThan(0);
210
209
  expect(rows.length).toBeLessThanOrEqual(16);
211
- expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
212
- expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
213
- expect(list?.parentElement?.style.height).toBe(
210
+ expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1");
211
+ expect(treegrid?.getAttribute("aria-rowcount")).toBe("1000");
212
+ expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
213
+ expect(treegrid?.querySelectorAll('[data-timeline-focus-id][tabindex="0"]')).toHaveLength(1);
214
+ expect(treegrid?.parentElement?.style.height).toBe(
214
215
  `${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
215
216
  );
216
217
 
@@ -226,7 +227,44 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
226
227
  scroller.dispatchEvent(new Event("scroll"));
227
228
  });
228
229
  }
229
- expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
230
+ expect(treegrid?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
231
+ expect(document.activeElement).toBe(focusedControl);
232
+
233
+ act(() => root.unmount());
234
+ usePlayerStore.getState().reset();
235
+ });
236
+
237
+ it("keeps focus pinning active after the scroll viewport remounts", async () => {
238
+ const [{ Timeline }, { usePlayerStore }] = await Promise.all([
239
+ import("./Timeline"),
240
+ import("../store/playerStore"),
241
+ ]);
242
+ usePlayerStore.setState({
243
+ duration: 60,
244
+ timelineReady: true,
245
+ elements: clipsByTrack(1_000),
246
+ });
247
+
248
+ const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 9 }));
249
+ await settleUntil(() => host.querySelectorAll("[data-timeline-row]").length > 0);
250
+ const firstScroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
251
+
252
+ await act(async () => usePlayerStore.setState({ timelineReady: false }));
253
+ expect(host.querySelector("[data-timeline-scroll-viewport]")).toBeNull();
254
+ await act(async () => usePlayerStore.setState({ timelineReady: true }));
255
+ await settleUntil(() => host.querySelectorAll("[data-timeline-row]").length > 0);
256
+
257
+ const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
258
+ const firstRow = host.querySelector<HTMLElement>('[data-timeline-row-key="0"]');
259
+ const focusedControl = firstRow?.querySelector<HTMLButtonElement>("button");
260
+ expect(scroller).not.toBe(firstScroller);
261
+ expect(focusedControl).not.toBeNull();
262
+ act(() => focusedControl?.focus());
263
+ if (scroller) {
264
+ scroller.scrollTop = 500 * 48;
265
+ await dispatchScroll(scroller);
266
+ }
267
+ expect(host.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
230
268
  expect(document.activeElement).toBe(focusedControl);
231
269
 
232
270
  act(() => root.unmount());
@@ -242,6 +280,8 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
242
280
  usePlayerStore.setState({
243
281
  duration: 1_000,
244
282
  timelineReady: true,
283
+ timelineProjectId: "project-a",
284
+ timelineSessionEpoch: 4,
245
285
  zoomMode: "manual",
246
286
  manualZoomPercent: 2_000,
247
287
  selectedElementId: "clip-490",
@@ -286,10 +326,13 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
286
326
  expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
287
327
  expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
288
328
 
289
- await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
329
+ const { timelineClipFocusId } = await import("./timelineNavigationIdentity");
330
+ await act(async () =>
331
+ usePlayerStore.getState().requestTimelineFocus(timelineClipFocusId("clip-300")),
332
+ );
290
333
  await advanceFrame();
291
334
  await act(async () => {});
292
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
335
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(timelineClipFocusId("clip-300"));
293
336
  const focusedClip = host.querySelector('[data-el-id="clip-300"]');
294
337
  expect(document.activeElement).toBe(focusedClip);
295
338
  await advanceFrame();
@@ -1,8 +1,7 @@
1
1
  import { memo } from "react";
2
2
  import { TimelineRuler } from "./TimelineRuler";
3
3
  import { PlayheadIndicator } from "./PlayheadIndicator";
4
- import { getTimelineEditCapabilities, type TimelineRangeSelection } from "./timelineEditing";
5
- import { getRenderedTimelineElement } from "./timelineTheme";
4
+ import type { TimelineRangeSelection } from "./timelineEditing";
6
5
  import {
7
6
  RULER_H,
8
7
  CLIP_Y,
@@ -19,13 +18,11 @@ import type { ResizingClipState } from "./useTimelineClipDrag";
19
18
  import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
20
19
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
21
20
  import type { Rect } from "../../utils/marqueeGeometry";
22
- import { TimelineClip } from "./TimelineClip";
23
21
  import { TimelineLanes } from "./TimelineLanes";
24
22
  import type { TimelineLaneBaseProps } from "./timelineLaneProps";
25
- import { renderClipChildren } from "./timelineClipChildren";
26
23
  import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
27
- import { isTimelineClipActive } from "./useTimelineActiveClips";
28
24
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
25
+ import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
29
26
 
30
27
  interface TimelineCanvasProps extends TimelineLaneBaseProps {
31
28
  major: number[];
@@ -65,15 +62,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
65
62
  const beatDragging = usePlayerStore((s) => s.beatDragging);
66
63
  const draggedElement = draggedClip?.element ?? null;
67
64
  const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null;
68
- const activeDraggedElement =
69
- draggedClip?.started === true && draggedElement && draggedElementIdentity
70
- ? getRenderedTimelineElement({
71
- element: draggedElement,
72
- draggedElementId: draggedElementIdentity,
73
- previewStart: draggedClip.previewStart,
74
- previewTrack: draggedClip.previewTrack,
75
- })
76
- : null;
77
65
  // The drag ghost follows the cursor freely (both axes) — CapCut-style. The
78
66
  // "magnetic" affordance is a highlight on the destination lane (draggedRowIndex),
79
67
  // which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it.
@@ -94,22 +82,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
94
82
  selectedKeys: selectedElementIds,
95
83
  }
96
84
  : null;
97
- const activeDraggedPosition =
98
- draggedClip?.started === true && activeDraggedElement && scrollRef.current
99
- ? {
100
- left:
101
- draggedClip.pointerClientX -
102
- scrollRef.current.getBoundingClientRect().left +
103
- scrollRef.current.scrollLeft -
104
- draggedClip.pointerOffsetX,
105
- top:
106
- draggedClip.pointerClientY -
107
- scrollRef.current.getBoundingClientRect().top +
108
- scrollRef.current.scrollTop -
109
- draggedClip.pointerOffsetY,
110
- }
111
- : null;
112
-
113
85
  return (
114
86
  <div
115
87
  className="relative"
@@ -235,47 +207,18 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
235
207
  />
236
208
  )}
237
209
 
238
- {/* Drag ghost */}
239
- {activeDraggedElement && activeDraggedPosition && (
240
- <div
241
- className="absolute pointer-events-none"
242
- style={{
243
- top: activeDraggedPosition.top,
244
- left: activeDraggedPosition.left,
245
- width: Math.max(activeDraggedElement.duration * props.pps, 4),
246
- height: draggedClipHeight,
247
- zIndex: 40,
248
- }}
249
- >
250
- <TimelineClip
251
- el={{ ...activeDraggedElement, start: 0 }}
252
- pps={props.pps}
253
- clipY={0}
254
- isSelected={
255
- props.selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)
256
- }
257
- isHovered={false}
258
- isDragging={true}
259
- isActive={isTimelineClipActive(activeDraggedElement, props.currentTime)}
260
- hasCustomContent={!!props.renderClipContent}
261
- capabilities={getTimelineEditCapabilities(activeDraggedElement)}
262
- theme={props.theme}
263
- isComposition={!!activeDraggedElement.compositionSrc}
264
- onHoverStart={() => {}}
265
- onHoverEnd={() => {}}
266
- onResizeStart={() => {}}
267
- onClick={() => {}}
268
- onDoubleClick={() => {}}
269
- >
270
- {renderClipChildren(
271
- activeDraggedElement,
272
- props.getTrackStyle(activeDraggedElement.tag),
273
- props.renderClipContent,
274
- props.renderClipOverlay,
275
- )}
276
- </TimelineClip>
277
- </div>
278
- )}
210
+ <TimelineGestureOverlay
211
+ drag={draggedClip}
212
+ scrollRef={scrollRef}
213
+ pixelsPerSecond={props.pps}
214
+ rowHeight={draggedClipHeight}
215
+ selectedElementId={props.selectedElementId}
216
+ currentTime={props.currentTime}
217
+ theme={props.theme}
218
+ getTrackStyle={props.getTrackStyle}
219
+ renderClipContent={props.renderClipContent}
220
+ renderClipOverlay={props.renderClipOverlay}
221
+ />
279
222
 
280
223
  {/* Marquee (rubber-band) multi-select rectangle — mirrors the canvas
281
224
  MarqueeOverlay look: semi-transparent accent fill + dashed border. */}
@@ -36,6 +36,7 @@ function renderClip({
36
36
  const host = document.createElement("div");
37
37
  document.body.append(host);
38
38
  const root = createRoot(host);
39
+ const onClick = vi.fn();
39
40
 
40
41
  act(() => {
41
42
  root.render(
@@ -50,7 +51,7 @@ function renderClip({
50
51
  isComposition={false}
51
52
  onHoverStart={vi.fn()}
52
53
  onHoverEnd={vi.fn()}
53
- onClick={vi.fn()}
54
+ onClick={onClick}
54
55
  onDoubleClick={vi.fn()}
55
56
  >
56
57
  <div data-custom-content="true" />
@@ -58,7 +59,7 @@ function renderClip({
58
59
  );
59
60
  });
60
61
 
61
- return { host, root };
62
+ return { host, onClick, root };
62
63
  }
63
64
 
64
65
  describe("TimelineClip", () => {
@@ -113,4 +114,18 @@ describe("TimelineClip", () => {
113
114
 
114
115
  act(() => root.unmount());
115
116
  });
117
+
118
+ it("is a roving native button with explicit selection semantics", () => {
119
+ const { host, onClick, root } = renderClip({
120
+ element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 2, track: 0 },
121
+ isSelected: true,
122
+ });
123
+ const clip = host.querySelector<HTMLButtonElement>(".timeline-clip")!;
124
+ expect(clip.type).toBe("button");
125
+ expect(clip.tabIndex).toBe(-1);
126
+ expect(clip.getAttribute("aria-pressed")).toBe("true");
127
+ act(() => clip.click());
128
+ expect(onClick).toHaveBeenCalledOnce();
129
+ act(() => root.unmount());
130
+ });
116
131
  });