@hyperframes/studio 0.7.54 → 0.7.56

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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -7,38 +7,66 @@ export const TRACK_H = 48;
7
7
  export const RULER_H = 24;
8
8
  export const CLIP_Y = 3;
9
9
  export const CLIP_HANDLE_W = 18;
10
- const TIMELINE_SCROLL_BUFFER = 20;
11
-
12
- /* ── Timeline duration ─────────────────────────────────────────────── */
10
+ /**
11
+ * Half-width (as a fraction of TRACK_H) of the new-track INSERT band that
12
+ * straddles each lane boundary. Deliberately equals the clip's vertical inset
13
+ * (`CLIP_Y / TRACK_H`): a clip body fills [CLIP_Y, TRACK_H − CLIP_Y] of its row,
14
+ * so the ONLY region this band covers is the visible empty gutter between two
15
+ * clip bodies (plus the top/bottom breathing pads, handled separately by the
16
+ * rowFloat ≤ 0 / ≥ trackCount extremes). Aiming at a clip body is therefore a
17
+ * move-to-that-lane; only the inter-clip gap arms an insert — see resolveInsertRow.
18
+ * Threaded into resolveInsertRow by the drag preview so the hit band can never
19
+ * drift from the rendered clip geometry.
20
+ */
21
+ export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
22
+ /**
23
+ * Breathing room INSIDE the scroll area (CapCut-style), threaded through every
24
+ * track-row y computation via {@link getTimelineRowTop} — never inline a magic
25
+ * offset; a track row's top is always `RULER_H + TRACKS_TOP_PAD + row*TRACK_H`.
26
+ *
27
+ * - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
28
+ * (~half a track height) so the first clip isn't jammed under the ruler.
29
+ * - TRACKS_BOTTOM_PAD: empty space below the last track (~1.5 track heights),
30
+ * enough to comfortably drag a clip into the void to create a new bottom lane.
31
+ */
32
+ export const TRACKS_TOP_PAD = 50;
33
+ export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
13
34
 
14
- // Committed timeline length: root duration or the furthest committed clip end,
15
- // with NO live drag/resize preview. This drives the zoom (fit-to-width pps) so
16
- // the pixels-per-second mapping stays fixed while you dragotherwise a clip
17
- // dragged past the end grows the duration, shrinks pps, and jumps under the
18
- // pointer (a positive-feedback loop). The zoom re-fits once on drop.
19
- export function computeTimelineBasisDuration(
20
- rootDuration: number,
21
- clipEnds: readonly number[],
22
- ): number {
23
- const safeDur = Number.isFinite(rootDuration) ? rootDuration : 0;
24
- if (clipEnds.length === 0) return safeDur;
25
- const maxEnd = Math.max(safeDur, ...clipEnds);
26
- return Number.isFinite(maxEnd) ? maxEnd : safeDur;
35
+ /**
36
+ * The y (content-space) of the top edge of track ROW index `row` (0 = first
37
+ * displayed lane). The single source of truth for row→y the ruler height plus
38
+ * the top breathing pad plus whole track lanes above it. Every clip/ghost/
39
+ * placeholder/insertion top and every pointer-y→row inversion goes through this
40
+ * (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
41
+ */
42
+ export function getTimelineRowTop(row: number): number {
43
+ return RULER_H + TRACKS_TOP_PAD + row * TRACK_H;
27
44
  }
28
45
 
29
- // Displayed length: the basis plus any active drag/resize preview end, so the
30
- // ruler and track width grow to follow a clip dragged past the current end
31
- // (with the zoom held fixed, the extra length becomes scrollable content).
32
- export function computeTimelineEffectiveDuration(
33
- basisDuration: number,
34
- previewEnds: readonly (number | null)[],
35
- ): number {
36
- let maxEnd = basisDuration;
37
- for (const end of previewEnds) {
38
- if (end != null && Number.isFinite(end)) maxEnd = Math.max(maxEnd, end);
39
- }
40
- return maxEnd;
46
+ /**
47
+ * Inverse of {@link getTimelineRowTop}: the fractional row index for a content-
48
+ * space y (used for insert-row / drop-lane decisions). Subtracts the ruler and
49
+ * top pad before dividing by the track height.
50
+ */
51
+ export function getTimelineRowFromY(contentY: number): number {
52
+ return (contentY - RULER_H - TRACKS_TOP_PAD) / TRACK_H;
41
53
  }
54
+ /**
55
+ * While a clip drag is live, the rendered timeline extends this far past the
56
+ * ghost's end so the right-edge auto-scroll zone always has room to keep
57
+ * stepping — that's what lets a drag extend the timeline past its current
58
+ * rendered width (see Timeline.tsx displayContentWidth).
59
+ */
60
+ export const DRAG_EXTEND_MARGIN_PX = 160;
61
+ /**
62
+ * The rendered timeline always spans at least this many seconds of ruler +
63
+ * track lanes, even when the composition is shorter — the empty space on the
64
+ * right is a real, drag/drop-enabled surface (clips can be moved into it; the
65
+ * composition grows on commit, content-driven). In fit mode the fit pps is
66
+ * derived against this floor, so a 10s comp renders as ~1/6 of the viewport
67
+ * with 60s of ruler after it.
68
+ */
69
+ export const MIN_TIMELINE_EXTENT_S = 60;
42
70
 
43
71
  /* ── Tick generation ──────────────────────────────────────────────── */
44
72
  function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
@@ -54,15 +82,15 @@ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): numbe
54
82
  return durationIntervals.find((interval) => interval >= target) ?? 60;
55
83
  }
56
84
 
57
- function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
58
- if (
59
- Number.isFinite(pixelsPerSecond) &&
60
- (pixelsPerSecond ?? 0) > 0 &&
61
- (majorInterval / 2) * (pixelsPerSecond ?? 0) < 12
62
- ) {
63
- return 0;
64
- }
65
- return majorInterval / 2;
85
+ // How many equal parts to split each major interval into for minor ticks. Prefer
86
+ // quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
87
+ // none (0) as ticks get too dense to read (< ~8px apart).
88
+ function getMinorSubdivisions(majorInterval: number, pixelsPerSecond?: number): number {
89
+ const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
90
+ if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
91
+ if ((majorInterval / 4) * pps >= 8) return 4;
92
+ if ((majorInterval / 2) * pps >= 8) return 2;
93
+ return 0;
66
94
  }
67
95
 
68
96
  export function generateTicks(
@@ -72,39 +100,25 @@ export function generateTicks(
72
100
  if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
73
101
  return { major: [], minor: [] };
74
102
  const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
75
- const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
103
+ const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond);
104
+ const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
76
105
  const major: number[] = [];
77
106
  const minor: number[] = [];
78
107
  const maxTicks = 2000; // Safety cap to prevent runaway tick generation
79
108
  for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
80
109
  const rounded = Math.round(t * 100) / 100;
81
110
  major.push(rounded);
82
- if (minorInterval > 0 && major.length + minor.length < maxTicks) {
83
- const midpoint = Math.round((t + minorInterval) * 100) / 100;
84
- if (midpoint <= duration + 0.001) minor.push(midpoint);
111
+ // Emit the (subdivisions - 1) minor ticks between this major and the next.
112
+ for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
113
+ const m = Math.round((t + k * minorInterval) * 100) / 100;
114
+ if (m <= duration + 0.001) minor.push(m);
85
115
  }
86
116
  }
87
117
  return { major, minor };
88
118
  }
89
119
 
90
- /**
91
- * Ticks spanning the full visible ruler width, not just the composition, so a
92
- * zoomed-out ruler stays filled with labels instead of ending mid-panel. The
93
- * major/minor interval is driven by pixelsPerSecond (pixel spacing), so widening
94
- * the range keeps spacing identical — it only adds ticks past the content end.
95
- */
96
- export function generateVisibleTicks(
97
- effectiveDuration: number,
98
- pixelsPerSecond: number,
99
- viewportWidth: number,
100
- gutter: number,
101
- ): { major: number[]; minor: number[] } {
102
- const visible = viewportWidth > gutter ? (viewportWidth - gutter) / pixelsPerSecond : 0;
103
- return generateTicks(Math.max(effectiveDuration, visible), pixelsPerSecond);
104
- }
105
-
106
120
  export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
107
- if (!Number.isFinite(time)) return "0:00";
121
+ if (!Number.isFinite(time)) return "00:00";
108
122
  const safeTime = Math.max(0, time);
109
123
  if (majorInterval < 0.1) {
110
124
  const totalHundredths = Math.round(safeTime * 100);
@@ -128,6 +142,45 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
128
142
  return formatTime(safeTime);
129
143
  }
130
144
 
145
+ /* ── Width / duration derivation ──────────────────────────────────── */
146
+ /**
147
+ * Fit-mode pixels-per-second: fill the viewport with the composition, but
148
+ * never map fewer than MIN_TIMELINE_EXTENT_S seconds onto it — a short comp
149
+ * takes a fraction of the width and the remaining ruler runs to 1:00.
150
+ * Manual zoom multiplies this base, so the floor only anchors the default.
151
+ */
152
+ export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
153
+ const safeDuration =
154
+ Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
155
+ const span = Math.max(safeDuration, MIN_TIMELINE_EXTENT_S);
156
+ if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
157
+ return (viewportWidth - GUTTER - 2) / span;
158
+ }
159
+
160
+ /**
161
+ * The rendered timeline extent in px. Always covers, whichever is largest:
162
+ * the actual clip content, the visible viewport (no dead black after short
163
+ * content — CapCut-style), a live drag or resize ghost plus the auto-scroll
164
+ * margin (drag/trim-to-extend), and the MIN_TIMELINE_EXTENT_S floor. Only the
165
+ * RENDERED extent grows; clip positions/durations are untouched.
166
+ */
167
+ export function getTimelineDisplayContentWidth(input: {
168
+ trackContentWidth: number;
169
+ viewportWidth: number;
170
+ pps: number;
171
+ dragGhostEndPx?: number;
172
+ resizeGhostEndPx?: number;
173
+ }): number {
174
+ const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
175
+ return Math.max(
176
+ input.trackContentWidth,
177
+ input.viewportWidth - GUTTER - 2,
178
+ input.dragGhostEndPx ?? 0,
179
+ input.resizeGhostEndPx ?? 0,
180
+ MIN_TIMELINE_EXTENT_S * safePps,
181
+ );
182
+ }
183
+
131
184
  /* ── Scroll / zoom helpers ────────────────────────────────────────── */
132
185
  export function shouldAutoScrollTimeline(
133
186
  zoomMode: ZoomMode,
@@ -180,7 +233,10 @@ export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number):
180
233
  }
181
234
 
182
235
  export function getTimelineCanvasHeight(trackCount: number): number {
183
- return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
236
+ // RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
237
+ // subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
238
+ // below the last lane is real scrollable surface, not a hidden buffer.
239
+ return RULER_H + TRACKS_TOP_PAD + Math.max(0, trackCount) * TRACK_H + TRACKS_BOTTOM_PAD;
184
240
  }
185
241
 
186
242
  /* ── UI helpers ───────────────────────────────────────────────────── */
@@ -245,12 +301,15 @@ export function resolveTimelineAssetDrop(
245
301
  clientY: number,
246
302
  ): { start: number; track: number } {
247
303
  const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
248
- const y = clientY - input.rectTop + input.scrollTop - RULER_H;
304
+ const contentY = clientY - input.rectTop + input.scrollTop;
249
305
  const start = Math.max(
250
306
  0,
251
307
  Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
252
308
  );
253
- const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1));
309
+ // Row from the shared row→y inverse so the top pad is honoured; a drop in the
310
+ // pad above the first lane floors to row 0, a drop in the bottom pad rounds
311
+ // past the last lane (getDefaultDroppedTrack then appends a new track).
312
+ const rowIndex = Math.floor(getTimelineRowFromY(contentY));
254
313
  return {
255
314
  start,
256
315
  track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ MARQUEE_DRAG_THRESHOLD_PX,
4
+ isMarqueeDrag,
5
+ isTimelineRulerPress,
6
+ getMarqueeRect,
7
+ getTimelineClipRect,
8
+ computeMarqueeSelection,
9
+ } from "./timelineMarquee";
10
+ import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout";
11
+
12
+ describe("isTimelineRulerPress", () => {
13
+ const rectTop = 500; // scroll container's viewport top
14
+
15
+ it("treats a press inside the ruler band as a ruler press (unscrolled)", () => {
16
+ expect(isTimelineRulerPress(rectTop, rectTop)).toBe(true);
17
+ expect(isTimelineRulerPress(rectTop + RULER_H - 1, rectTop)).toBe(true);
18
+ });
19
+
20
+ it("treats a press below the ruler band as a body press", () => {
21
+ expect(isTimelineRulerPress(rectTop + RULER_H, rectTop)).toBe(false);
22
+ expect(isTimelineRulerPress(rectTop + RULER_H + 100, rectTop)).toBe(false);
23
+ });
24
+
25
+ it("stays a ruler press when the body is scrolled down (sticky ruler)", () => {
26
+ // The ruler is position:sticky — scrolled down, its VISUAL band is still
27
+ // the top RULER_H px of the container. Content-space math
28
+ // (clientY - rectTop + scrollTop) would report y = 10 + 300 = 310 ≥ RULER_H
29
+ // and misclassify this as a body/marquee press; viewport-space math must
30
+ // still classify it as a ruler press regardless of scrollTop.
31
+ const scrollTop = 300;
32
+ const clientY = rectTop + 10; // visually on the stuck ruler
33
+ const contentSpaceY = clientY - rectTop + scrollTop;
34
+ expect(contentSpaceY).toBeGreaterThanOrEqual(RULER_H); // the old, broken signal
35
+ expect(isTimelineRulerPress(clientY, rectTop)).toBe(true);
36
+ });
37
+
38
+ it("honors a custom ruler height", () => {
39
+ expect(isTimelineRulerPress(rectTop + 30, rectTop, 32)).toBe(true);
40
+ expect(isTimelineRulerPress(rectTop + 33, rectTop, 32)).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("isMarqueeDrag", () => {
45
+ it("treats sub-threshold movement as a click, not a drag", () => {
46
+ expect(isMarqueeDrag(100, 50, 100, 50)).toBe(false);
47
+ expect(
48
+ isMarqueeDrag(
49
+ 100,
50
+ 50,
51
+ 100 + MARQUEE_DRAG_THRESHOLD_PX - 1,
52
+ 50 + MARQUEE_DRAG_THRESHOLD_PX - 1,
53
+ ),
54
+ ).toBe(false);
55
+ });
56
+
57
+ it("becomes a drag once either axis passes the threshold", () => {
58
+ expect(isMarqueeDrag(100, 50, 100 + MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true);
59
+ expect(isMarqueeDrag(100, 50, 100, 50 + MARQUEE_DRAG_THRESHOLD_PX)).toBe(true);
60
+ expect(isMarqueeDrag(100, 50, 100 - MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("getMarqueeRect", () => {
65
+ it("builds a rect from origin to current for a down-right drag", () => {
66
+ expect(getMarqueeRect(10, 20, 110, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
67
+ });
68
+
69
+ it("normalizes negative drags (up-left) into a positive rect", () => {
70
+ expect(getMarqueeRect(110, 70, 10, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
71
+ });
72
+
73
+ it("normalizes mixed-direction drags (down-left / up-right)", () => {
74
+ expect(getMarqueeRect(110, 20, 10, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
75
+ expect(getMarqueeRect(10, 70, 110, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
76
+ });
77
+
78
+ it("yields a zero-size rect when the pointer has not moved", () => {
79
+ expect(getMarqueeRect(42, 42, 42, 42)).toEqual({ left: 42, top: 42, width: 0, height: 0 });
80
+ });
81
+ });
82
+
83
+ describe("getTimelineClipRect", () => {
84
+ const trackOrder = [0, 2, 5];
85
+
86
+ it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
87
+ const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100);
88
+ expect(rect).toEqual({
89
+ left: GUTTER + 200,
90
+ top: getTimelineRowTop(1) + CLIP_Y,
91
+ width: 300,
92
+ height: TRACK_H - CLIP_Y * 2,
93
+ });
94
+ });
95
+
96
+ it("places the first visible track below the ruler + top breathing pad", () => {
97
+ const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50);
98
+ expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
99
+ expect(rect?.left).toBe(GUTTER);
100
+ });
101
+
102
+ it("uses the row index in trackOrder, not the raw track number", () => {
103
+ const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50);
104
+ expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
105
+ });
106
+
107
+ it("enforces the 4px minimum rendered width", () => {
108
+ const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, trackOrder, 10);
109
+ expect(rect?.width).toBe(4);
110
+ });
111
+
112
+ it("returns null for a track that is not displayed or an invalid pps", () => {
113
+ expect(getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100)).toBeNull();
114
+ expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0)).toBeNull();
115
+ expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN)).toBeNull();
116
+ });
117
+ });
118
+
119
+ describe("computeMarqueeSelection", () => {
120
+ // Two visible tracks: row 0 = track 0, row 1 = track 1. pps 100.
121
+ const trackOrder = [0, 1];
122
+ const pps = 100;
123
+ const clips = [
124
+ { id: "a", start: 0, duration: 1, track: 0 }, // x [32,132], row 0
125
+ { id: "b", start: 2, duration: 1, track: 0 }, // x [232,332], row 0
126
+ { id: "c", start: 0.5, duration: 1, track: 1 }, // x [82,182], row 1
127
+ ];
128
+ const row0Top = getTimelineRowTop(0) + CLIP_Y;
129
+ const row1Top = getTimelineRowTop(1) + CLIP_Y;
130
+
131
+ it("selects only the clips the marquee rect intersects", () => {
132
+ const marquee = { left: GUTTER, top: row0Top, width: 50, height: 10 };
133
+ const { ids, primaryId } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
134
+ expect(ids).toEqual(new Set(["a"]));
135
+ expect(primaryId).toBe("a");
136
+ });
137
+
138
+ it("selects across tracks when the rect spans multiple rows", () => {
139
+ const marquee = { left: GUTTER, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
140
+ const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
141
+ expect(ids).toEqual(new Set(["a", "c"]));
142
+ });
143
+
144
+ it("excludes clips outside the rect horizontally", () => {
145
+ const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
146
+ const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
147
+ expect(ids).toEqual(new Set());
148
+ });
149
+
150
+ it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
151
+ const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
152
+ const { ids, primaryId } = computeMarqueeSelection({
153
+ clips,
154
+ trackOrder,
155
+ pps,
156
+ marquee,
157
+ baseSelection: ["b"],
158
+ });
159
+ expect(ids).toEqual(new Set(["b"]));
160
+ expect(primaryId).toBeNull();
161
+ });
162
+
163
+ it("unions additive base selection with new hits; primary comes from the marquee", () => {
164
+ const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
165
+ const { ids, primaryId } = computeMarqueeSelection({
166
+ clips,
167
+ trackOrder,
168
+ pps,
169
+ marquee,
170
+ baseSelection: ["b"],
171
+ });
172
+ expect(ids).toEqual(new Set(["b", "c"]));
173
+ expect(primaryId).toBe("c");
174
+ });
175
+
176
+ it("shrinking the rect live drops clips it no longer covers", () => {
177
+ const wide = { left: GUTTER, top: row0Top, width: 320, height: 10 };
178
+ const narrow = { left: GUTTER, top: row0Top, width: 80, height: 10 };
179
+ expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: wide }).ids).toEqual(
180
+ new Set(["a", "b"]),
181
+ );
182
+ expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: narrow }).ids).toEqual(
183
+ new Set(["a"]),
184
+ );
185
+ });
186
+
187
+ it("ignores clips on hidden/undisplayed tracks", () => {
188
+ const marquee = { left: 0, top: 0, width: 10000, height: 10000 };
189
+ const { ids } = computeMarqueeSelection({
190
+ clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
191
+ trackOrder,
192
+ pps,
193
+ marquee,
194
+ });
195
+ expect(ids).toEqual(new Set());
196
+ });
197
+ });
@@ -0,0 +1,112 @@
1
+ import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout";
2
+ import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
3
+
4
+ /** Pointer must travel at least this far (either axis) before a pointerdown on
5
+ * the empty timeline body becomes a marquee drag instead of a plain click. */
6
+ export const MARQUEE_DRAG_THRESHOLD_PX = 4;
7
+
8
+ /** Minimum rendered clip width, mirrors TimelineClip's `Math.max(w, 4)`. */
9
+ const MIN_CLIP_W = 4;
10
+
11
+ export interface MarqueeClipInput {
12
+ id: string;
13
+ start: number;
14
+ duration: number;
15
+ track: number;
16
+ }
17
+
18
+ /**
19
+ * Ruler-vs-body decision for a pointerdown on the timeline scroll container.
20
+ *
21
+ * The ruler is `position: sticky; top: 0` — once the body is scrolled down its
22
+ * VISUAL position stays pinned to the container top while its LAYOUT position
23
+ * scrolls away. The hit test must therefore use VIEWPORT-space y (clientY
24
+ * relative to the scroll container's bounding rect), NOT content-space y
25
+ * (clientY - rect.top + scrollTop), which misclassifies a press on the stuck
26
+ * ruler as a body/marquee press whenever scrollTop > 0.
27
+ */
28
+ export function isTimelineRulerPress(
29
+ clientY: number,
30
+ scrollRectTop: number,
31
+ rulerHeight: number = RULER_H,
32
+ ): boolean {
33
+ return clientY - scrollRectTop < rulerHeight;
34
+ }
35
+
36
+ export function isMarqueeDrag(
37
+ originX: number,
38
+ originY: number,
39
+ currentX: number,
40
+ currentY: number,
41
+ threshold: number = MARQUEE_DRAG_THRESHOLD_PX,
42
+ ): boolean {
43
+ return Math.abs(currentX - originX) >= threshold || Math.abs(currentY - originY) >= threshold;
44
+ }
45
+
46
+ /** Normalized marquee rect (canvas/content coordinates) from the drag origin and
47
+ * the current pointer — handles drags in any direction (negative deltas). */
48
+ export function getMarqueeRect(
49
+ originX: number,
50
+ originY: number,
51
+ currentX: number,
52
+ currentY: number,
53
+ ): Rect {
54
+ return {
55
+ left: Math.min(originX, currentX),
56
+ top: Math.min(originY, currentY),
57
+ width: Math.abs(currentX - originX),
58
+ height: Math.abs(currentY - originY),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * A clip's rendered rect in canvas/content coordinates (the same space the
64
+ * marquee rect lives in): x from GUTTER + start * pps, y from the clip's row
65
+ * index within the visible track order (RULER_H + row * TRACK_H + CLIP_Y).
66
+ * Returns null when the clip's track is not currently displayed.
67
+ */
68
+ export function getTimelineClipRect(
69
+ clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
70
+ trackOrder: number[],
71
+ pps: number,
72
+ ): Rect | null {
73
+ const row = trackOrder.indexOf(clip.track);
74
+ if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
75
+ return {
76
+ left: GUTTER + clip.start * pps,
77
+ top: getTimelineRowTop(row) + CLIP_Y,
78
+ width: Math.max(clip.duration * pps, MIN_CLIP_W),
79
+ height: TRACK_H - CLIP_Y * 2,
80
+ };
81
+ }
82
+
83
+ export interface MarqueeSelectionResult {
84
+ /** Every clip id the marquee currently covers (plus the additive base). */
85
+ ids: Set<string>;
86
+ /** The last marquee-hit clip in element order — the primary selection.
87
+ * Null when the marquee covers nothing new (caller keeps its current primary). */
88
+ primaryId: string | null;
89
+ }
90
+
91
+ /**
92
+ * Live marquee selection: every clip whose rendered rect intersects the marquee.
93
+ * `baseSelection` (shift/cmd-additive) is unioned in but never affects primaryId.
94
+ */
95
+ export function computeMarqueeSelection(input: {
96
+ clips: MarqueeClipInput[];
97
+ trackOrder: number[];
98
+ pps: number;
99
+ marquee: Rect;
100
+ baseSelection?: Iterable<string>;
101
+ }): MarqueeSelectionResult {
102
+ const ids = new Set<string>(input.baseSelection ?? []);
103
+ let primaryId: string | null = null;
104
+ for (const clip of input.clips) {
105
+ const rect = getTimelineClipRect(clip, input.trackOrder, input.pps);
106
+ if (rect && rectsOverlap(rect, input.marquee)) {
107
+ ids.add(clip.id);
108
+ primaryId = clip.id;
109
+ }
110
+ }
111
+ return { ids, primaryId };
112
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ clampGroupMoveDelta,
4
+ isMultiDragActive,
5
+ isMultiDragPassenger,
6
+ multiDragDeltaSeconds,
7
+ multiDragPassengerOffsetPx,
8
+ type MultiDragPreviewInput,
9
+ } from "./timelineMultiDragPreview";
10
+
11
+ const base = (over: Partial<MultiDragPreviewInput> = {}): MultiDragPreviewInput => ({
12
+ dragStarted: true,
13
+ draggedKey: "a",
14
+ draggedOriginStart: 2,
15
+ draggedPreviewStart: 5,
16
+ selectedKeys: new Set(["a", "b", "c"]),
17
+ ...over,
18
+ });
19
+
20
+ describe("isMultiDragActive", () => {
21
+ it("is active when a started drag's clip is part of a 2+ selection", () => {
22
+ expect(isMultiDragActive(base())).toBe(true);
23
+ });
24
+
25
+ it("is inactive before the drag starts", () => {
26
+ expect(isMultiDragActive(base({ dragStarted: false }))).toBe(false);
27
+ });
28
+
29
+ it("is inactive for a single-clip selection (single-drag behavior)", () => {
30
+ expect(isMultiDragActive(base({ selectedKeys: new Set(["a"]) }))).toBe(false);
31
+ });
32
+
33
+ it("is inactive when the dragged clip is not itself selected", () => {
34
+ expect(isMultiDragActive(base({ draggedKey: "z" }))).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("multiDragDeltaSeconds (the one formation delta)", () => {
39
+ it("is the grabbed clip's preview − origin start when active", () => {
40
+ // The preview start is already group-clamped upstream, so this delta is the
41
+ // clamped delta every member (ghost + passengers) moves by.
42
+ expect(multiDragDeltaSeconds(base())).toBe(3);
43
+ });
44
+
45
+ it("supports a leftward (negative) delta", () => {
46
+ expect(multiDragDeltaSeconds(base({ draggedPreviewStart: 0.5 }))).toBeCloseTo(-1.5);
47
+ });
48
+
49
+ it("is zero when no multi-drag is active", () => {
50
+ expect(multiDragDeltaSeconds(base({ selectedKeys: new Set(["a"]) }))).toBe(0);
51
+ });
52
+ });
53
+
54
+ describe("isMultiDragPassenger", () => {
55
+ it("marks a selected non-dragged clip as a passenger", () => {
56
+ expect(isMultiDragPassenger("b", base())).toBe(true);
57
+ expect(isMultiDragPassenger("c", base())).toBe(true);
58
+ });
59
+
60
+ it("never marks the dragged clip itself (it is the free ghost)", () => {
61
+ expect(isMultiDragPassenger("a", base())).toBe(false);
62
+ });
63
+
64
+ it("never marks an unselected clip", () => {
65
+ expect(isMultiDragPassenger("d", base())).toBe(false);
66
+ });
67
+
68
+ it("marks nothing when the drag is a single-drag", () => {
69
+ const single = base({ selectedKeys: new Set(["a"]) });
70
+ expect(isMultiDragPassenger("b", single)).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)", () => {
75
+ it("converts the one formation delta to pixels for every passenger", () => {
76
+ // Both passengers move by the SAME 3s × 100pps = 300px — spacing locked.
77
+ expect(multiDragPassengerOffsetPx("b", 100, base())).toBe(300);
78
+ expect(multiDragPassengerOffsetPx("c", 100, base())).toBe(300);
79
+ });
80
+
81
+ it("is zero for the dragged clip and for non-passengers", () => {
82
+ expect(multiDragPassengerOffsetPx("a", 100, base())).toBe(0);
83
+ expect(multiDragPassengerOffsetPx("d", 100, base())).toBe(0);
84
+ });
85
+
86
+ it("is zero for a non-finite pps", () => {
87
+ expect(multiDragPassengerOffsetPx("b", Number.NaN, base())).toBe(0);
88
+ });
89
+
90
+ it("follows a leftward delta", () => {
91
+ expect(multiDragPassengerOffsetPx("c", 50, base({ draggedPreviewStart: 0 }))).toBe(-100);
92
+ });
93
+ });
94
+
95
+ describe("clampGroupMoveDelta (rigid group move)", () => {
96
+ it("passes a rightward delta through unchanged (no right wall)", () => {
97
+ expect(clampGroupMoveDelta(3, [2, 5, 9])).toBe(3);
98
+ expect(clampGroupMoveDelta(1000, [0, 4])).toBe(1000);
99
+ });
100
+
101
+ it("passes a leftward delta through when no member would cross 0", () => {
102
+ // Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped.
103
+ expect(clampGroupMoveDelta(-3, [5, 8, 12])).toBe(-3);
104
+ });
105
+
106
+ it("clamps a leftward delta so the leftmost member stops exactly at 0", () => {
107
+ // Leftmost at 2 → the furthest left the group can move is -2 (that member → 0).
108
+ // A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
109
+ // formation instead of out-running it.
110
+ expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2);
111
+ });
112
+
113
+ it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => {
114
+ // Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1.
115
+ expect(clampGroupMoveDelta(-8, [10, 1, 4])).toBe(-1);
116
+ });
117
+
118
+ it("already-at-0 member forbids any leftward move", () => {
119
+ expect(clampGroupMoveDelta(-4, [0, 3, 7])).toBe(0);
120
+ // rightward still allowed
121
+ expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2);
122
+ });
123
+
124
+ it("returns the raw delta for an empty formation", () => {
125
+ expect(clampGroupMoveDelta(-9, [])).toBe(-9);
126
+ });
127
+ });