@hyperframes/studio 0.7.55 → 0.7.57

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 (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -7,104 +7,172 @@ 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;
70
+ /**
71
+ * Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` — not the bare
72
+ * duration — onto the viewport, so the composition ends at ~83% of the width
73
+ * and the trailing ~17% stays empty ruler + droppable lane surface (room to
74
+ * drag clips past the current end without first zooming out). Applied ONLY
75
+ * inside {@link getTimelineFitPps}, the single fit-pps source, so the ruler,
76
+ * lanes, playhead, marquee, and drag math all inherit it consistently. Manual
77
+ * zoom percentages stay defined relative to this fit basis (100% == fit).
78
+ */
79
+ export const FIT_ZOOM_HEADROOM = 1.2;
42
80
 
43
81
  /* ── Tick generation ──────────────────────────────────────────────── */
44
- function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
45
- const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
82
+ // fallow-ignore-next-line complexity
83
+ function getMajorTickInterval(
84
+ duration: number,
85
+ pixelsPerSecond?: number,
86
+ frameRate?: number,
87
+ ): number {
88
+ // "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
89
+ // minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
90
+ // readable (non-colliding) labels instead of the old 10m fallback everywhere.
91
+ const zoomIntervals = [
92
+ 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
93
+ ];
94
+ let interval: number;
46
95
  if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
47
96
  const targetMajorPx = 88;
48
- return (
49
- zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
50
- );
97
+ interval =
98
+ zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
99
+ 3600;
100
+ } else {
101
+ const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
102
+ const target = duration / 6;
103
+ interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
104
+ }
105
+ // Frame display mode: labels are frame numbers, so a major step must be a
106
+ // WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
107
+ // (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
108
+ // the label spacing never drops below the readability target.
109
+ if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
110
+ const fps = frameRate ?? 0;
111
+ return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
51
112
  }
52
- const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
53
- const target = duration / 6;
54
- return durationIntervals.find((interval) => interval >= target) ?? 60;
113
+ return interval;
55
114
  }
56
115
 
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;
116
+ // How many equal parts to split each major interval into for minor ticks. Prefer
117
+ // quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
118
+ // none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
119
+ // the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
120
+ // sub-frame time is not a seekable position), so only divisors of the major
121
+ // step's frame count qualify — quarters, then fifths (15/30-frame majors),
122
+ // thirds, halves.
123
+ // fallow-ignore-next-line complexity
124
+ function getMinorSubdivisions(
125
+ majorInterval: number,
126
+ pixelsPerSecond?: number,
127
+ frameRate?: number,
128
+ ): number {
129
+ const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
130
+ if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
131
+ const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
132
+ const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
133
+ const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
134
+ for (const parts of candidates) {
135
+ if (fps > 0 && majorFrames % parts !== 0) continue;
136
+ if ((majorInterval / parts) * pps >= 8) return parts;
64
137
  }
65
- return majorInterval / 2;
138
+ return 0;
139
+ }
140
+
141
+ // Ticks are exact multiples of the interval (multiplied per index, never
142
+ // accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
143
+ // keep values/keys clean without disturbing frame-exact positions like 2/30s.
144
+ function roundTickValue(t: number): number {
145
+ return Math.round(t * 1e6) / 1e6;
66
146
  }
67
147
 
68
148
  export function generateTicks(
69
149
  duration: number,
70
150
  pixelsPerSecond?: number,
151
+ frameRate?: number,
71
152
  ): { major: number[]; minor: number[] } {
72
- if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
153
+ if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
73
154
  return { major: [], minor: [] };
74
- const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
75
- const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
155
+ const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
156
+ const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
157
+ const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
76
158
  const major: number[] = [];
77
159
  const minor: number[] = [];
78
160
  const maxTicks = 2000; // Safety cap to prevent runaway tick generation
79
- for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
80
- const rounded = Math.round(t * 100) / 100;
81
- 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);
161
+ for (let i = 0; major.length < maxTicks; i++) {
162
+ const t = i * majorInterval;
163
+ if (t > duration + 0.001) break;
164
+ major.push(roundTickValue(t));
165
+ // Emit the (subdivisions - 1) minor ticks between this major and the next.
166
+ for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
167
+ const m = t + k * minorInterval;
168
+ if (m <= duration + 0.001) minor.push(roundTickValue(m));
85
169
  }
86
170
  }
87
171
  return { major, minor };
88
172
  }
89
173
 
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
174
  export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
107
- if (!Number.isFinite(time)) return "0:00";
175
+ if (!Number.isFinite(time)) return "00:00";
108
176
  const safeTime = Math.max(0, time);
109
177
  if (majorInterval < 0.1) {
110
178
  const totalHundredths = Math.round(safeTime * 100);
@@ -128,6 +196,47 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
128
196
  return formatTime(safeTime);
129
197
  }
130
198
 
199
+ /* ── Width / duration derivation ──────────────────────────────────── */
200
+ /**
201
+ * Fit-mode pixels-per-second: fill the viewport with the composition plus
202
+ * FIT_ZOOM_HEADROOM trailing headroom (CapCut-style — the comp never slams
203
+ * into the right edge), and never map fewer than MIN_TIMELINE_EXTENT_S
204
+ * seconds onto it — a short comp takes a fraction of the width and the
205
+ * remaining ruler runs to 1:00.
206
+ * Manual zoom multiplies this base, so the floor only anchors the default.
207
+ */
208
+ export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
209
+ const safeDuration =
210
+ Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
211
+ const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
212
+ if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
213
+ return (viewportWidth - GUTTER - 2) / span;
214
+ }
215
+
216
+ /**
217
+ * The rendered timeline extent in px. Always covers, whichever is largest:
218
+ * the actual clip content, the visible viewport (no dead black after short
219
+ * content — CapCut-style), a live drag or resize ghost plus the auto-scroll
220
+ * margin (drag/trim-to-extend), and the MIN_TIMELINE_EXTENT_S floor. Only the
221
+ * RENDERED extent grows; clip positions/durations are untouched.
222
+ */
223
+ export function getTimelineDisplayContentWidth(input: {
224
+ trackContentWidth: number;
225
+ viewportWidth: number;
226
+ pps: number;
227
+ dragGhostEndPx?: number;
228
+ resizeGhostEndPx?: number;
229
+ }): number {
230
+ const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
231
+ return Math.max(
232
+ input.trackContentWidth,
233
+ input.viewportWidth - GUTTER - 2,
234
+ input.dragGhostEndPx ?? 0,
235
+ input.resizeGhostEndPx ?? 0,
236
+ MIN_TIMELINE_EXTENT_S * safePps,
237
+ );
238
+ }
239
+
131
240
  /* ── Scroll / zoom helpers ────────────────────────────────────────── */
132
241
  export function shouldAutoScrollTimeline(
133
242
  zoomMode: ZoomMode,
@@ -174,13 +283,33 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
174
283
  }
175
284
 
176
285
  /* ── Playhead / canvas ────────────────────────────────────────────── */
286
+ /**
287
+ * Width of the playhead wrapper element (== the diamond head chip's layout
288
+ * width, which the wrapper shrink-wraps to). The 1px vertical line inside
289
+ * PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
290
+ * shifted LEFT by half this width for the line's center to land exactly on
291
+ * `GUTTER + time * pps` — see {@link getTimelinePlayheadLeft}.
292
+ */
293
+ export const PLAYHEAD_HEAD_W = 9;
294
+
295
+ /**
296
+ * The `left` for the playhead WRAPPER such that the vertical line's CENTER
297
+ * sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
298
+ * on) at every zoom level. Without the half-head offset the line sat
299
+ * `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
300
+ */
177
301
  export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
178
- if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
179
- return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
302
+ if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
303
+ return GUTTER - PLAYHEAD_HEAD_W / 2;
304
+ }
305
+ return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
180
306
  }
181
307
 
182
308
  export function getTimelineCanvasHeight(trackCount: number): number {
183
- return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
309
+ // RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
310
+ // subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
311
+ // below the last lane is real scrollable surface, not a hidden buffer.
312
+ return RULER_H + TRACKS_TOP_PAD + Math.max(0, trackCount) * TRACK_H + TRACKS_BOTTOM_PAD;
184
313
  }
185
314
 
186
315
  /* ── UI helpers ───────────────────────────────────────────────────── */
@@ -245,12 +374,15 @@ export function resolveTimelineAssetDrop(
245
374
  clientY: number,
246
375
  ): { start: number; track: number } {
247
376
  const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
248
- const y = clientY - input.rectTop + input.scrollTop - RULER_H;
377
+ const contentY = clientY - input.rectTop + input.scrollTop;
249
378
  const start = Math.max(
250
379
  0,
251
380
  Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
252
381
  );
253
- const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1));
382
+ // Row from the shared row→y inverse so the top pad is honoured; a drop in the
383
+ // pad above the first lane floors to row 0, a drop in the bottom pad rounds
384
+ // past the last lane (getDefaultDroppedTrack then appends a new track).
385
+ const rowIndex = Math.floor(getTimelineRowFromY(contentY));
254
386
  return {
255
387
  start,
256
388
  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
+ }