@hyperframes/studio 0.8.49 → 0.8.51

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 (281) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-Cn_PpHqP.js} +3 -3
  2. package/dist/assets/index-C05SNrvY.js +360 -0
  3. package/dist/assets/{index-mIgSsKqs.js → index-CPS3Cvj8.js} +1 -1
  4. package/dist/assets/{index-BGovDksE.js → index-Clrb11lj.js} +1 -1
  5. package/dist/assets/index-DUR9qzQM.css +1 -0
  6. package/dist/index.d.ts +29 -16
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7283 -8606
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +11 -12
  12. package/src/App.tsx +132 -143
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  15. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  16. package/src/captions/components/CaptionTimeline.tsx +1 -1
  17. package/src/captions/components/shared.tsx +2 -2
  18. package/src/components/AskAgentModal.tsx +5 -5
  19. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  20. package/src/components/EditorShell.tsx +28 -7
  21. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  22. package/src/components/LintModal.tsx +6 -16
  23. package/src/components/MediaPreview.tsx +2 -2
  24. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  25. package/src/components/StudioHeader.tsx +2 -62
  26. package/src/components/StudioLeftSidebar.tsx +7 -4
  27. package/src/components/StudioOverlays.tsx +2 -6
  28. package/src/components/StudioRightPanel.tsx +4 -4
  29. package/src/components/StudioToast.tsx +2 -2
  30. package/src/components/TimelineToolbar.tsx +7 -7
  31. package/src/components/dock/Dock.test.tsx +192 -0
  32. package/src/components/dock/Dock.tsx +264 -0
  33. package/src/components/dock/dock.css +23 -0
  34. package/src/components/dock/dockLayout.ts +58 -0
  35. package/src/components/dock/dockLayoutSchema.ts +66 -0
  36. package/src/components/dock/dockLayoutStore.ts +91 -0
  37. package/src/components/dock/panelRegistry.ts +67 -0
  38. package/src/components/editor/AnimationCard.tsx +4 -4
  39. package/src/components/editor/AnimationCardParts.tsx +6 -6
  40. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  41. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  42. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  43. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  44. package/src/components/editor/DomEditOverlay.tsx +1 -1
  45. package/src/components/editor/EaseCurveSection.tsx +1 -1
  46. package/src/components/editor/EaseParamFields.tsx +5 -5
  47. package/src/components/editor/FileTree.tsx +4 -4
  48. package/src/components/editor/FileTreeIcons.tsx +1 -1
  49. package/src/components/editor/FileTreeNodes.tsx +13 -13
  50. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  51. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  52. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  53. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  54. package/src/components/editor/LayersPanel.tsx +3 -3
  55. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  56. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  57. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  58. package/src/components/editor/PreviewGuides.tsx +125 -0
  59. package/src/components/editor/PromotableControl.tsx +2 -2
  60. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  61. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  62. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  63. package/src/components/editor/SnapToolbar.tsx +27 -3
  64. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  65. package/src/components/editor/TopologyLens.tsx +4 -4
  66. package/src/components/editor/Transform3DCube.tsx +2 -2
  67. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  68. package/src/components/editor/domEditing.test.ts +1 -1
  69. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  70. package/src/components/editor/domEditingAgentPrompt.ts +9 -3
  71. package/src/components/editor/previewGuidesStore.ts +26 -0
  72. package/src/components/editor/propertyPanelColor.tsx +11 -11
  73. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  74. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  75. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  76. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  77. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  78. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  79. package/src/components/editor/propertyPanelFill.tsx +4 -4
  80. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  81. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  82. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  83. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  84. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  85. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  86. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  87. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  88. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  89. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  90. package/src/components/editor/propertyPanelFont.tsx +5 -5
  91. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  92. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  93. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  94. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  95. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  96. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  97. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  98. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  99. package/src/components/editor/propertyPanelSections.tsx +8 -8
  100. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  101. package/src/components/feedback/feedbackTrigger.ts +0 -1
  102. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  103. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  104. package/src/components/nle/NLEPreview.test.ts +13 -0
  105. package/src/components/nle/NLEPreview.tsx +12 -7
  106. package/src/components/nle/PreviewOverlays.tsx +6 -6
  107. package/src/components/nle/PreviewPane.test.tsx +52 -0
  108. package/src/components/nle/PreviewPane.tsx +4 -2
  109. package/src/components/nle/TimelinePane.tsx +17 -3
  110. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  111. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  112. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  113. package/src/components/panels/SlideshowPanel.tsx +2 -2
  114. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  115. package/src/components/panels/VariablesBindElement.tsx +2 -2
  116. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  117. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  118. package/src/components/panels/VariablesPanel.tsx +3 -3
  119. package/src/components/panels/VariablesValueControls.tsx +2 -2
  120. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  121. package/src/components/renders/RenderQueue.test.tsx +7 -0
  122. package/src/components/renders/RenderQueue.tsx +6 -6
  123. package/src/components/renders/RenderQueueItem.tsx +6 -6
  124. package/src/components/sidebar/AssetCard.tsx +8 -8
  125. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  126. package/src/components/sidebar/AssetsTab.tsx +2 -2
  127. package/src/components/sidebar/AudioRow.tsx +6 -6
  128. package/src/components/sidebar/BlocksTab.tsx +6 -6
  129. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  130. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  131. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  132. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  133. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  134. package/src/components/ui/Button.test.tsx +60 -0
  135. package/src/components/ui/Button.tsx +4 -4
  136. package/src/components/ui/SearchInput.tsx +2 -2
  137. package/src/components/ui/Tooltip.tsx +1 -1
  138. package/src/hooks/appHotkeysDispatch.ts +11 -0
  139. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  140. package/src/hooks/timelineTrackVisibility.ts +8 -11
  141. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  142. package/src/hooks/useAppHotkeys.ts +4 -1
  143. package/src/hooks/useAskAgentModal.ts +2 -0
  144. package/src/hooks/useClipboard.test.ts +234 -0
  145. package/src/hooks/useClipboard.ts +264 -70
  146. package/src/hooks/useGroupCommits.test.ts +0 -1
  147. package/src/hooks/useLintModal.test.tsx +65 -0
  148. package/src/hooks/useLintModal.ts +3 -0
  149. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  150. package/src/hooks/useRazorSplit.test.ts +0 -2
  151. package/src/hooks/useRazorSplit.test.tsx +0 -1
  152. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  153. package/src/hooks/useSdkSession.ts +43 -10
  154. package/src/hooks/useStudioContextValue.ts +5 -19
  155. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  156. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  157. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  158. package/src/main.tsx +9 -3
  159. package/src/player/components/AudioWaveform.tsx +1 -1
  160. package/src/player/components/AutomationSelectionMenu.tsx +2 -2
  161. package/src/player/components/ClipContextMenu.tsx +90 -10
  162. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  163. package/src/player/components/CompositionThumbnail.tsx +3 -4
  164. package/src/player/components/EditModal.tsx +1 -1
  165. package/src/player/components/ImageThumbnail.tsx +1 -1
  166. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  167. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  168. package/src/player/components/PlayerControls.tsx +1 -1
  169. package/src/player/components/ShortcutsPanel.tsx +9 -9
  170. package/src/player/components/SpeedMenu.tsx +3 -5
  171. package/src/player/components/Timeline.tsx +16 -15
  172. package/src/player/components/TimelineCanvas.tsx +45 -8
  173. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  174. package/src/player/components/TimelineEmptyState.tsx +5 -5
  175. package/src/player/components/TimelineFxButton.tsx +6 -6
  176. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  177. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  178. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  179. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  180. package/src/player/components/TimelineGroupRow.tsx +1 -1
  181. package/src/player/components/TimelineLanes.test.tsx +54 -1
  182. package/src/player/components/TimelineLanes.tsx +2 -5
  183. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  184. package/src/player/components/TimelineOverlays.tsx +12 -0
  185. package/src/player/components/TimelineRuler.tsx +1 -1
  186. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  187. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  188. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  189. package/src/player/components/TimelineTypes.ts +6 -1
  190. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  191. package/src/player/components/VideoThumbnail.tsx +4 -2
  192. package/src/player/components/VolumeControl.tsx +2 -2
  193. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  194. package/src/player/components/timelineCallbacks.ts +8 -8
  195. package/src/player/components/timelineClipChildren.test.ts +2 -2
  196. package/src/player/components/timelineClipChildren.tsx +2 -1
  197. package/src/player/components/timelineClipDragCommit.ts +2 -2
  198. package/src/player/components/timelineClipDragPreview.ts +51 -27
  199. package/src/player/components/timelineClipDragTypes.ts +3 -0
  200. package/src/player/components/timelineDragDrop.test.tsx +92 -1
  201. package/src/player/components/timelineDragDrop.ts +82 -33
  202. package/src/player/components/timelineEditCapabilities.ts +1 -2
  203. package/src/player/components/timelineEditing.test.ts +0 -16
  204. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  205. package/src/player/components/timelineGroupEditing.ts +0 -1
  206. package/src/player/components/timelineLaneProps.ts +3 -0
  207. package/src/player/components/timelineSnapping.test.ts +27 -0
  208. package/src/player/components/timelineSnapping.ts +14 -1
  209. package/src/player/components/timelineTrackDisplay.ts +1 -1
  210. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  211. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  212. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  213. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  214. package/src/player/components/useTimelineClipDrag.ts +30 -3
  215. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  216. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  217. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  218. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  219. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  220. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  221. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  222. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  224. package/src/player/lib/describeClips.test.ts +95 -0
  225. package/src/player/lib/describeClips.ts +52 -0
  226. package/src/player/lib/timelineDOM.test.ts +0 -80
  227. package/src/player/lib/timelineDOM.ts +1 -61
  228. package/src/player/lib/timelineElementHelpers.ts +2 -53
  229. package/src/player/store/playerStore.ts +5 -0
  230. package/src/player/store/timelineElement.ts +1 -3
  231. package/src/shims/canvasBrowserStub.js +50 -0
  232. package/src/styles/iconTokens.ts +20 -0
  233. package/src/styles/palette-reset.css +6 -0
  234. package/src/styles/studio.css +24 -21
  235. package/src/styles/tailwind-preset.shared.js +3 -0
  236. package/src/styles/theme.css +213 -0
  237. package/src/styles/theme.test.ts +196 -0
  238. package/src/utils/clipboardPayload.test.ts +42 -2
  239. package/src/utils/clipboardPayload.ts +57 -32
  240. package/src/utils/previewSafeMargins.test.ts +34 -0
  241. package/src/utils/previewSafeMargins.ts +10 -0
  242. package/src/utils/razorSplitTransaction.test.ts +0 -1
  243. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  244. package/src/utils/studioUiPreferences.test.ts +43 -0
  245. package/src/utils/studioUiPreferences.ts +34 -6
  246. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  247. package/src/utils/timelineDropTrackInsert.ts +83 -0
  248. package/src/utils/timelineElementSplit.test.ts +1 -2
  249. package/src/utils/timelineElementSplit.ts +0 -1
  250. package/src/webmcp/tools/lookTools.test.ts +40 -0
  251. package/src/webmcp/tools/lookTools.ts +32 -1
  252. package/dist/assets/index-B9LfWNyM.css +0 -1
  253. package/dist/assets/index-Bgmx2h7W.js +0 -441
  254. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  255. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  256. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  257. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  258. package/src/components/storyboard/FramePoster.tsx +0 -86
  259. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  260. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  261. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  262. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  263. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  264. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  265. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  266. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  267. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  268. package/src/components/storyboard/StoryboardView.tsx +0 -169
  269. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  270. package/src/components/storyboard/frameComments.test.ts +0 -95
  271. package/src/components/storyboard/frameComments.ts +0 -115
  272. package/src/components/storyboard/frameStatus.ts +0 -36
  273. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  274. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  275. package/src/components/storyboard/useFrameComments.ts +0 -98
  276. package/src/contexts/ViewModeContext.tsx +0 -142
  277. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  278. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  279. package/src/hooks/useStoryboard.ts +0 -99
  280. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  281. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -70,6 +70,9 @@ interface RenderLanesOptions {
70
70
  draggedClip?: DraggedClipState | null;
71
71
  onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
72
72
  onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
73
+ hoveredClip?: string | null;
74
+ renderClipContent?: React.ComponentProps<typeof TimelineLanes>["renderClipContent"];
75
+ snapGuide?: { time: number; type: "beat" | "clip-edge" | "playhead" } | null;
73
76
  }
74
77
 
75
78
  function renderLanes(options: RenderLanesOptions = {}): {
@@ -135,9 +138,11 @@ function renderLanes(options: RenderLanesOptions = {}): {
135
138
  laneCounts={laneCounts}
136
139
  selectedElementId={null}
137
140
  selectedElementIds={next.selectedElementIds ?? new Set()}
138
- hoveredClip={null}
141
+ hoveredClip={next.hoveredClip ?? null}
142
+ renderClipContent={next.renderClipContent}
139
143
  draggedClip={next.draggedClip ?? null}
140
144
  draggedElement={null}
145
+ snapGuide={next.snapGuide ?? null}
141
146
  multiDragPreview={next.multiDragPreview ?? null}
142
147
  blockedClipRef={createRef<BlockedClipState | null>()}
143
148
  suppressClickRef={{ current: false }}
@@ -176,6 +181,36 @@ function visibilityLabels(host: HTMLElement): (string | null)[] {
176
181
  );
177
182
  }
178
183
 
184
+ /** The beat guide's own highlight div, keyed by the green glow every other beat lacks. */
185
+ function beatHighlight(host: HTMLElement): HTMLElement | undefined {
186
+ return Array.from(host.querySelectorAll("div")).find((div) =>
187
+ (div.style.boxShadow ?? "").includes("34,197,94"),
188
+ );
189
+ }
190
+
191
+ describe("TimelineLanes beat guide", () => {
192
+ it("draws the beat highlight from snapGuide, not from the stale draggedClip prop", () => {
193
+ const view = renderLanes({
194
+ elements: [element("clip-a", TRACK_A)],
195
+ snapGuide: { time: 1.5, type: "beat" },
196
+ });
197
+
198
+ expect(beatHighlight(view.host)?.style.left).toBe("150px");
199
+ act(() => view.root.unmount());
200
+ });
201
+
202
+ it("clears the highlight once the trim it belonged to ends", () => {
203
+ const view = renderLanes({
204
+ elements: [element("clip-a", TRACK_A)],
205
+ snapGuide: { time: 1.5, type: "beat" },
206
+ });
207
+ view.rerender({ elements: [element("clip-a", TRACK_A)], snapGuide: null });
208
+
209
+ expect(beatHighlight(view.host)).toBeUndefined();
210
+ act(() => view.root.unmount());
211
+ });
212
+ });
213
+
179
214
  describe("TimelineLanes track numbering", () => {
180
215
  // Screen readers literally announced "Hide track 0.16666666666666666".
181
216
  it("numbers tracks contiguously from 1 regardless of the fractional sort keys", () => {
@@ -395,3 +430,21 @@ describe("TimelineLanes selection", () => {
395
430
  act(() => view.root.unmount());
396
431
  });
397
432
  });
433
+
434
+ describe("TimelineLanes clip thumbnails", () => {
435
+ it("asks for the same frames at rest, hovered and selected", () => {
436
+ const rich: unknown[] = [];
437
+ const renderClipContent = vi.fn(
438
+ (_el: TimelineElement, _style: unknown, context: { rich: boolean }) => {
439
+ rich.push(context.rich);
440
+ return null;
441
+ },
442
+ );
443
+ const elements = [element("clip-a", TRACK_A)];
444
+ const view = renderLanes({ elements, renderClipContent });
445
+ view.rerender({ elements, renderClipContent, hoveredClip: "clip-a" });
446
+ view.rerender({ elements, renderClipContent, selectedElementIds: new Set(["clip-a"]) });
447
+ expect(new Set(rich)).toEqual(new Set([false]));
448
+ act(() => view.root.unmount());
449
+ });
450
+ });
@@ -59,6 +59,7 @@ export function TimelineLanes({
59
59
  hoveredClip,
60
60
  draggedClip,
61
61
  draggedElement,
62
+ snapGuide,
62
63
  multiDragPreview,
63
64
  blockedClipRef,
64
65
  suppressClickRef,
@@ -331,11 +332,7 @@ export function TimelineLanes({
331
332
  beatTimes={beatAnalysis?.beatTimes}
332
333
  beatStrengths={beatAnalysis?.beatStrengths}
333
334
  pps={pps}
334
- highlightTime={
335
- draggedClip?.started && draggedClip.snapType === "beat"
336
- ? draggedClip.snapTime
337
- : null
338
- }
335
+ highlightTime={snapGuide?.type === "beat" ? snapGuide.time : null}
339
336
  renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
340
337
  />
341
338
  {/* Beat dots on the active track (the one holding the selection),
@@ -56,14 +56,14 @@ export function TimelineLayerGroupHeader({
56
56
  }}
57
57
  >
58
58
  <div
59
- className="sticky left-0 z-[13] flex h-full items-center"
59
+ className="sticky left-0 z-13 flex h-full items-center"
60
60
  style={{
61
61
  width: Math.min(GUTTER + 220, GUTTER + trackContentWidth),
62
62
  background: theme.gutterBackground,
63
63
  }}
64
64
  >
65
65
  <div
66
- className="relative h-full flex-shrink-0"
66
+ className="relative h-full shrink-0"
67
67
  style={{
68
68
  width: GUTTER,
69
69
  borderRight: `1px solid ${theme.gutterBorder}`,
@@ -52,6 +52,10 @@ interface TimelineOverlaysProps {
52
52
  onSplitElement: TimelineEditCallbacks["onSplitElement"];
53
53
  pinZoomBeforeEdit: () => void;
54
54
  onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
55
+ onCopyClip?: () => boolean;
56
+ onPasteClip?: () => Promise<void>;
57
+ onDuplicateClip?: () => Promise<boolean>;
58
+ canPasteClip?: () => boolean;
55
59
  gapContextMenu: TrackGapContextMenuState | null;
56
60
  onDismissGapContextMenu: () => void;
57
61
  onCloseTrackGap: () => void;
@@ -119,6 +123,10 @@ export function TimelineOverlays({
119
123
  onSplitElement,
120
124
  pinZoomBeforeEdit,
121
125
  onDeleteElement,
126
+ onCopyClip,
127
+ onPasteClip,
128
+ onDuplicateClip,
129
+ canPasteClip,
122
130
  gapContextMenu,
123
131
  onDismissGapContextMenu,
124
132
  onCloseTrackGap,
@@ -249,6 +257,10 @@ export function TimelineOverlays({
249
257
  pinZoomBeforeEdit();
250
258
  onDeleteElement?.(element);
251
259
  }}
260
+ onCopy={onCopyClip}
261
+ onPaste={onPasteClip}
262
+ onDuplicate={onDuplicateClip}
263
+ canPaste={canPasteClip?.() ?? false}
252
264
  />
253
265
  )}
254
266
 
@@ -85,7 +85,7 @@ export const TimelineRuler = memo(function TimelineRuler({
85
85
  style={{ height: RULER_H, width: contentOrigin + trackContentWidth, zIndex: 70 }}
86
86
  >
87
87
  <div
88
- className="sticky left-0 z-[12] flex-shrink-0"
88
+ className="sticky left-0 z-12 shrink-0"
89
89
  style={{
90
90
  width: contentOrigin,
91
91
  // Ruler corner uses the panel surface — same as the ruler strip itself.
@@ -12,7 +12,7 @@ export function TimelineShortcutHint({ theme }: TimelineShortcutHintProps) {
12
12
  style={{ background: "rgba(17,23,35,0.84)", borderColor: theme.gutterBorder }}
13
13
  >
14
14
  <kbd
15
- className="text-[9px] font-mono px-1 py-0.5 rounded"
15
+ className="text-[9px] font-mono px-1 py-0.5 rounded-sm"
16
16
  style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
17
17
  >
18
18
  Shift
@@ -259,7 +259,7 @@ export function TimelineTrackHeader({
259
259
  <div
260
260
  role="rowheader"
261
261
  aria-colindex={1}
262
- className="sticky left-0 z-[12] shrink-0"
262
+ className="sticky left-0 z-12 shrink-0"
263
263
  style={{
264
264
  width: showTrackLabel ? LABEL_COL_W : contentOrigin,
265
265
  background: gutterFill(theme.gutterBackground, isGroupMember),
@@ -39,7 +39,7 @@ export function VisibilityButton({
39
39
  type="button"
40
40
  aria-label={label}
41
41
  title={label}
42
- className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
42
+ className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-[#3CE6AC] ${
43
43
  hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
44
44
  }`}
45
45
  onPointerDown={(event) => event.stopPropagation()}
@@ -97,10 +97,10 @@ export function PlainTrackHeader({
97
97
 
98
98
  Wraps rather than truncating: a truncated name needs a hover to be
99
99
  read at all, and a tooltip is no use to a name you are scanning a
100
- column of. `break-words` so a long single token breaks instead of
100
+ column of. `wrap-break-word` so a long single token breaks instead of
101
101
  forcing the column wider. */}
102
102
  {showTrackLabel && (
103
- <span className="min-w-0 break-words text-[11px] leading-tight">{trackLabel}</span>
103
+ <span className="min-w-0 wrap-break-word text-[11px] leading-tight">{trackLabel}</span>
104
104
  )}
105
105
  {showTrackLabel && <TrackClipCount clipCount={clipCount} />}
106
106
  {/* `ml-auto` is what anchors the group right: it absorbs the slack the
@@ -12,7 +12,8 @@ export interface TimelineClipRenderContext {
12
12
  export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
13
13
  /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
14
14
  sessionEpoch?: number;
15
- onSeek?: (time: number) => void;
15
+ /** keepPlaying: true preserves the current play state across the seek. */
16
+ onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
16
17
  onDrillDown?: (element: TimelineElement) => void;
17
18
  renderClipContent?: (
18
19
  element: TimelineElement,
@@ -22,5 +23,9 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverri
22
23
  renderClipOverlay?: (element: TimelineElement) => ReactNode;
23
24
  onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
24
25
  onSelectElement?: (element: TimelineElement | null) => void;
26
+ onCopyClip?: () => boolean;
27
+ onPasteClip?: () => Promise<void>;
28
+ onDuplicateClip?: () => Promise<boolean>;
29
+ canPasteClip?: () => boolean;
25
30
  theme?: Partial<TimelineTheme>;
26
31
  }
@@ -75,7 +75,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
75
75
  return createPortal(
76
76
  <div
77
77
  ref={menuRef}
78
- className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
78
+ className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
79
79
  style={{ left: adjustedX, top: adjustedY }}
80
80
  onPointerLeave={() => onHoverAction(null)}
81
81
  >
@@ -94,7 +94,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
94
94
  return (
95
95
  <div
96
96
  key={index}
97
- className="relative h-full flex-shrink-0 overflow-hidden bg-neutral-900"
97
+ className="relative h-full shrink-0 overflow-hidden bg-neutral-900"
98
98
  style={{ width: frameW }}
99
99
  >
100
100
  <img
@@ -119,7 +119,9 @@ export const VideoThumbnail = memo(function VideoThumbnail({
119
119
  )}
120
120
  {snapshot.status === "error" && (
121
121
  <div className="absolute inset-0 flex items-center justify-center bg-neutral-900/60">
122
- <span className="rounded bg-black/50 px-1 text-[8px] text-neutral-500">no preview</span>
122
+ <span className="rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
123
+ no preview
124
+ </span>
123
125
  </div>
124
126
  )}
125
127
  {label && (
@@ -52,7 +52,7 @@ export const VolumeControl = memo(function VolumeControl({
52
52
  const muteLabel = silent ? "Unmute audio" : "Mute audio";
53
53
 
54
54
  return (
55
- <div className="group flex flex-shrink-0 items-center">
55
+ <div className="group flex shrink-0 items-center">
56
56
  <div className="w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-150 ease-out group-hover:w-14 group-hover:opacity-100 group-focus-within:w-14 group-focus-within:opacity-100">
57
57
  <div className="relative mx-1 flex h-6 w-12 items-center">
58
58
  <div className="absolute inset-x-0 h-0.5 overflow-hidden rounded-full bg-neutral-700">
@@ -92,7 +92,7 @@ export const VolumeControl = memo(function VolumeControl({
92
92
  disabled={disabled}
93
93
  aria-label={muteLabel}
94
94
  aria-pressed={silent}
95
- className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
95
+ className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
96
96
  silent ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
97
97
  }`}
98
98
  >
@@ -3,7 +3,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
3
3
  import { getTimelineEditCapabilities } from "./timelineEditing";
4
4
  import type { DraggedClipState } from "./timelineClipDragTypes";
5
5
 
6
- /** Whether Studio may write timing to this clip (false for locked/implicit rows). */
6
+ /** Whether Studio may write timing to this clip (false for locked rows). */
7
7
  export function canMoveTimelineElement(element: TimelineElement): boolean {
8
8
  return getTimelineEditCapabilities({
9
9
  tag: element.tag,
@@ -16,7 +16,6 @@ export function canMoveTimelineElement(element: TimelineElement): boolean {
16
16
  playbackStart: element.playbackStart,
17
17
  playbackStartAttr: element.playbackStartAttr,
18
18
  sourceDuration: element.sourceDuration,
19
- timingSource: element.timingSource,
20
19
  timelineLocked: element.timelineLocked,
21
20
  }).canMove;
22
21
  }
@@ -14,20 +14,20 @@ export interface TimelinePropertyGroupKeyframeToggle {
14
14
  remove: boolean;
15
15
  }
16
16
 
17
+ /** Where an outside drop lands; `insertRow` opens a new track at that row boundary of `trackOrder`. */
18
+ export type TimelineDropPlacement = { start: number; track: number } & (
19
+ | { insertRow?: null; trackOrder?: undefined }
20
+ | { insertRow: number; trackOrder: readonly number[] }
21
+ );
22
+
17
23
  /**
18
24
  * Shared callback signatures for timeline editing operations.
19
25
  * Used by NLELayout, Timeline, and any component that passes through
20
26
  * the standard set of timeline mutation handlers.
21
27
  */
22
28
  export interface TimelineDropCallbacks {
23
- onFileDrop?: (
24
- files: File[],
25
- placement?: { start: number; track: number },
26
- ) => Promise<void> | void;
27
- onAssetDrop?: (
28
- assetPath: string,
29
- placement: { start: number; track: number },
30
- ) => Promise<void> | void;
29
+ onFileDrop?: (files: File[], placement?: TimelineDropPlacement) => Promise<void> | void;
30
+ onAssetDrop?: (assetPath: string, placement: TimelineDropPlacement) => Promise<void> | void;
31
31
  onBlockDrop?: (
32
32
  blockName: string,
33
33
  placement: { start: number; track: number },
@@ -11,10 +11,10 @@ const clip: TimelineElement = {
11
11
  };
12
12
 
13
13
  describe("resolveClipRenderContext", () => {
14
- it("prioritizes interactive clips and enables rich thumbnails", () => {
14
+ it("prioritizes interactive clips without changing their thumbnail frames", () => {
15
15
  expect(resolveClipRenderContext(clip, { start: 0, end: 1 }, true)).toEqual({
16
16
  priority: "interaction",
17
- rich: true,
17
+ rich: false,
18
18
  });
19
19
  });
20
20
 
@@ -9,7 +9,8 @@ export function resolveClipRenderContext(
9
9
  visibleTimeRange: TimelineTimeRange,
10
10
  interactive: boolean,
11
11
  ): TimelineClipRenderContext {
12
- if (interactive) return { priority: "interaction", rich: true };
12
+ // Interaction only reorders loading; `rich` would swap the frames under the pointer.
13
+ if (interactive) return { priority: "interaction", rich: false };
13
14
  const visible =
14
15
  element.start < visibleTimeRange.end &&
15
16
  element.start + element.duration > visibleTimeRange.start;
@@ -176,8 +176,8 @@ export function persistMoveEdits(
176
176
  /**
177
177
  * Build the time-shift resolver for a multi-selection drag: every member of the
178
178
  * selection moves by the dragged clip's delta (clamped ≥ 0); non-members are
179
- * untouched. Returns null when this is not a multi-selection drag. A locked /
180
- * implicit member is dropped from the moving set (a marquee can sweep one in).
179
+ * untouched. Returns null when this is not a multi-selection drag. A locked
180
+ * member is dropped from the moving set (a marquee can sweep one in).
181
181
  */
182
182
  function resolveMultiSelection(
183
183
  drag: DraggedClipState,
@@ -12,6 +12,7 @@ import {
12
12
  snapMoveToTargets,
13
13
  snapTimelineTime,
14
14
  type TimelineSnapTarget,
15
+ type TimelineSnapType,
15
16
  } from "./timelineSnapping";
16
17
  import { resolveInsertRow, resolveZoneDropPlacement } from "./timelineCollision";
17
18
  import {
@@ -21,11 +22,13 @@ import {
21
22
  import { clampGroupMoveDelta } from "./timelineMultiDragPreview";
22
23
  import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
23
24
  import { resolveDragLandingStart } from "./timelineDragLanding";
25
+ import { STUDIO_PREVIEW_FPS } from "../lib/time";
24
26
 
25
27
  /** Snap-target builder closure supplied by the hook (closes over refs + store). */
26
28
  type BuildSnapTargets = (
27
29
  excludeElementKey: string | null,
28
30
  includeBeats: boolean,
31
+ includePlayhead?: boolean,
29
32
  ) => TimelineSnapTarget[];
30
33
 
31
34
  export interface DragPreviewContext {
@@ -223,6 +226,14 @@ export function computeDragPreview(
223
226
  };
224
227
  }
225
228
 
229
+ /** One frame: the last visible frame of a clip sits just before its end time. */
230
+ const TRIM_END_FRAME_LEAD_S = 1 / STUDIO_PREVIEW_FPS;
231
+
232
+ /** The composition time whose frame a trim shows: the edge being dragged. */
233
+ export function trimPreviewTime(edge: "start" | "end", start: number, duration: number): number {
234
+ return edge === "start" ? start : Math.max(start, start + duration - TRIM_END_FRAME_LEAD_S);
235
+ }
236
+
226
237
  export interface ResizePreviewContext {
227
238
  scroll: HTMLDivElement | null;
228
239
  pps: number;
@@ -234,6 +245,9 @@ export interface ResizePreviewResult {
234
245
  previewStart: number;
235
246
  previewDuration: number;
236
247
  previewPlaybackStart?: number;
248
+ /** The target the trimmed edge snapped to; null when the edge is free. */
249
+ snapTime: number | null;
250
+ snapType: TimelineSnapType | null;
237
251
  }
238
252
 
239
253
  /** Compute the trim preview for a pointer x (pure — the hook applies the state). */
@@ -287,28 +301,31 @@ export function computeResizePreview(
287
301
  effectiveClientX,
288
302
  );
289
303
 
290
- // Snap edge to unified targets (beats + clip edges + playhead) when available.
291
- // The snap must stay inside the same limits resolveTimelineResize enforces, or
292
- // it would push the edge past the available source media / composition end.
293
- // The music track defines the beats, so it must not snap to them — but it
294
- // still snaps to the playhead and other clip edges.
304
+ // Snap to beats and clip edges, never the playhead (the dragged edge drives
305
+ // its own preview seek, so that would be circular). Stay inside the same
306
+ // limits resolveTimelineResize enforces. The music track defines the
307
+ // beats, so it must not snap to them, but still snaps to clip edges.
295
308
  const trimTargets = buildSnapTargets(
296
309
  resize.element.key ?? resize.element.id,
297
310
  !isMusicTrack(resize.element),
311
+ false,
298
312
  );
313
+ let snap: TimelineSnapTarget | null = null;
299
314
  if (trimTargets.length > 0) {
300
315
  const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1);
301
316
  if (resize.edge === "end") {
302
317
  const edgeTime = nextResize.start + nextResize.duration;
303
- const snapped = snapTimelineTime(edgeTime, trimTargets, snapSecs).time;
318
+ const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs);
304
319
  // Stay within [start+minDuration, maxEnd] so the snap can't create a
305
320
  // degenerate clip or run past the source/composition limit.
306
321
  const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
307
- if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
308
- nextResize = { ...nextResize, duration: snappedDuration };
322
+ if (target && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
323
+ // An edge already on the target still owns the guide; only move it when off.
324
+ if (snapped !== edgeTime) nextResize = { ...nextResize, duration: snappedDuration };
325
+ snap = target;
309
326
  }
310
327
  } else {
311
- const snapped = snapTimelineTime(nextResize.start, trimTargets, snapSecs).time;
328
+ const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
312
329
  const delta = nextResize.start - snapped; // >0 when snapping left
313
330
  // Leftward snap reveals more source; cap so playbackStart can't go < 0.
314
331
  const maxLeftDelta =
@@ -318,22 +335,20 @@ export function computeResizePreview(
318
335
  // Also require the resulting duration to stay >= minDuration so a rightward
319
336
  // snap (delta < 0) can't collapse the clip to zero/negative.
320
337
  const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
321
- if (
322
- snapped !== nextResize.start &&
323
- snapped >= 0 &&
324
- delta <= maxLeftDelta + 1e-6 &&
325
- snappedDuration >= 0.05
326
- ) {
327
- nextResize = {
328
- ...nextResize,
329
- start: snapped,
330
- duration: snappedDuration,
331
- playbackStart:
332
- nextResize.playbackStart != null
333
- ? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
334
- 1000
335
- : undefined,
336
- };
338
+ if (target && snapped >= 0 && delta <= maxLeftDelta + 1e-6 && snappedDuration >= 0.05) {
339
+ if (snapped !== nextResize.start) {
340
+ nextResize = {
341
+ ...nextResize,
342
+ start: snapped,
343
+ duration: snappedDuration,
344
+ playbackStart:
345
+ nextResize.playbackStart != null
346
+ ? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
347
+ 1000
348
+ : undefined,
349
+ };
350
+ }
351
+ snap = target;
337
352
  }
338
353
  }
339
354
  }
@@ -343,6 +358,8 @@ export function computeResizePreview(
343
358
  previewStart: nextResize.start,
344
359
  previewDuration: nextResize.duration,
345
360
  previewPlaybackStart: nextResize.playbackStart,
361
+ snapTime: snap?.time ?? null,
362
+ snapType: snap?.type ?? null,
346
363
  };
347
364
  }
348
365
 
@@ -359,11 +376,18 @@ export function previewGroupResize(
359
376
  ) => void,
360
377
  ): void {
361
378
  const grabbedChange = applyTimelineGroupResizePreview(session, next);
379
+ const previewStart = grabbedChange?.start ?? next.previewStart;
380
+ const previewDuration = grabbedChange?.duration ?? next.previewDuration;
381
+ // A member clamp can pull the grabbed edge off the raw snap target; then no guide.
382
+ const edgeTime = session.edge === "end" ? previewStart + previewDuration : previewStart;
383
+ const stillSnapped = next.snapTime != null && Math.abs(edgeTime - next.snapTime) < 1e-3;
362
384
  setResizeState({
363
385
  originScrollLeft: next.originScrollLeft,
364
- previewStart: grabbedChange?.start ?? next.previewStart,
365
- previewDuration: grabbedChange?.duration ?? next.previewDuration,
386
+ previewStart,
387
+ previewDuration,
366
388
  previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart,
389
+ snapTime: stillSnapped ? next.snapTime : null,
390
+ snapType: stillSnapped ? next.snapType : null,
367
391
  groupPreview: session.changes,
368
392
  });
369
393
  }
@@ -57,6 +57,9 @@ export interface ResizingClipState {
57
57
  previewStart: number;
58
58
  previewDuration: number;
59
59
  previewPlaybackStart?: number;
60
+ /** Snap target the trimmed edge landed on, for the guide highlight. */
61
+ snapTime?: number | null;
62
+ snapType?: TimelineSnapType | null;
60
63
  /** Coordinator-owned group projection; canonical elements change only on commit. */
61
64
  groupPreview?: readonly {
62
65
  key: string;
@@ -6,7 +6,8 @@ import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
6
6
  import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
7
7
  import { usePlayerStore } from "../store/playerStore";
8
8
  import { createTimelineRowGeometry } from "./timelineLayout";
9
- import { useTimelineAssetDrop } from "./timelineDragDrop";
9
+ import { resolveDropInsertRow, useTimelineAssetDrop } from "./timelineDragDrop";
10
+ import { getTimelineRowTop, TRACK_H } from "./timelineLayout";
10
11
  import { configureTimelineTestViewport } from "./timelineTestViewport";
11
12
 
12
13
  (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -220,4 +221,94 @@ describe("useTimelineAssetDrop", () => {
220
221
  expect(view.api.isDragOver).toBe(false);
221
222
  act(() => view.root.unmount());
222
223
  });
224
+
225
+ it("previews the exact placement the drop commits, and clears it after", () => {
226
+ const onAssetDrop = vi.fn();
227
+ const view = renderHarness(onAssetDrop);
228
+ const payload = JSON.stringify({ path: "assets/a.png" });
229
+
230
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, 100)));
231
+ const preview = view.api.dropPreview;
232
+ expect(preview).toEqual({ start: 10, track: 0 });
233
+
234
+ act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, 100)));
235
+ expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
236
+ expect(view.api.dropPreview).toBeNull();
237
+ act(() => view.root.unmount());
238
+ });
239
+
240
+ it("moves the preview with the pointer and drops it when the drag leaves", () => {
241
+ const view = renderHarness(vi.fn());
242
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
243
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, 100)));
244
+ expect(view.api.dropPreview?.start).toBe(20);
245
+
246
+ act(() =>
247
+ view.api.handleAssetDragLeave({
248
+ relatedTarget: null,
249
+ currentTarget: document.body,
250
+ } as unknown as React.DragEvent),
251
+ );
252
+ expect(view.api.dropPreview).toBeNull();
253
+ act(() => view.root.unmount());
254
+ });
255
+ });
256
+
257
+ describe("resolveDropInsertRow", () => {
258
+ const rows = [TRACK_H, TRACK_H, TRACK_H];
259
+ it("arms a new track at the boundary between two lanes", () => {
260
+ expect(resolveDropInsertRow(getTimelineRowTop(1, rows), rows, 3)).toBe(1);
261
+ });
262
+ it("arms a new track above the first lane", () => {
263
+ expect(resolveDropInsertRow(getTimelineRowTop(0, rows) - 4, rows, 3)).toBe(0);
264
+ });
265
+ it("stays on the lane when the pointer is over its middle", () => {
266
+ expect(resolveDropInsertRow(getTimelineRowTop(1, rows) + TRACK_H / 2, rows, 3)).toBeNull();
267
+ });
268
+ it("leaves the area below the last lane to the append path", () => {
269
+ expect(resolveDropInsertRow(getTimelineRowTop(2, rows) + TRACK_H + 4, rows, 3)).toBeNull();
270
+ });
271
+ it("never arms on an empty timeline", () => {
272
+ expect(resolveDropInsertRow(80, [], 0)).toBeNull();
273
+ });
274
+ });
275
+
276
+ describe("useTimelineAssetDrop new-track drops", () => {
277
+ it("previews and commits an insert row for an asset dropped between lanes", () => {
278
+ const onAssetDrop = vi.fn();
279
+ const view = renderHarness(onAssetDrop);
280
+ const transfer = assetTransfer(JSON.stringify({ path: "assets/a.png" }));
281
+ const y = getTimelineRowTop(1) + 1;
282
+
283
+ act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, y)));
284
+ expect(view.api.dropPreview).toMatchObject({ start: 10, insertRow: 1 });
285
+ act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, y)));
286
+ expect(onAssetDrop).toHaveBeenCalledWith(
287
+ "assets/a.png",
288
+ expect.objectContaining({
289
+ start: 10,
290
+ insertRow: 1,
291
+ trackOrder: Array.from({ length: 100 }, (_, index) => index),
292
+ }),
293
+ );
294
+ act(() => view.root.unmount());
295
+ });
296
+
297
+ it("does not ask a block drop to insert a track", () => {
298
+ const onBlockDrop = vi.fn();
299
+ const view = renderHarness(vi.fn(), 1, { onBlockDrop });
300
+ const transfer: DropTransfer = {
301
+ types: [TIMELINE_BLOCK_MIME],
302
+ files: [],
303
+ dropEffect: "none",
304
+ getData: (type) => (type === TIMELINE_BLOCK_MIME ? JSON.stringify({ name: "b" }) : ""),
305
+ };
306
+ const y = getTimelineRowTop(1) + 1;
307
+ act(() => {
308
+ view.api.handleAssetDragOver(dragEvent(transfer, 400, y));
309
+ view.api.handleAssetDrop(dragEvent(transfer, 400, y));
310
+ });
311
+ expect(onBlockDrop).toHaveBeenCalledWith("b", { start: 10, track: 1 });
312
+ act(() => view.root.unmount());
313
+ });
223
314
  });