@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
@@ -1,52 +1,337 @@
1
+ import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing";
1
2
  import type { TimelineElement } from "../store/playerStore";
2
- import type { TimelineStackingReorderIntent } from "./timelineEditing";
3
- import type { TimelineLayerId } from "./timelineTrackOrder";
3
+ import { TRACK_H, getTimelineRowFromY, INSERT_BOUNDARY_BAND } from "./timelineLayout";
4
+ import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
5
+ import {
6
+ TIMELINE_SNAP_PX,
7
+ snapMoveToTargets,
8
+ snapTimelineTime,
9
+ type TimelineSnapTarget,
10
+ } from "./timelineSnapping";
11
+ import { resolveInsertRow, resolveZoneDropPlacement } from "./timelineCollision";
12
+ import {
13
+ applyTimelineGroupResizePreview,
14
+ type TimelineGroupResizeSession,
15
+ } from "./timelineGroupEditing";
16
+ import { clampGroupMoveDelta } from "./timelineMultiDragPreview";
17
+ import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
4
18
 
5
- interface DragPreviewState {
6
- element: Pick<TimelineElement, "track">;
7
- previewLayerId: TimelineLayerId;
8
- previewLayerIndex: number;
19
+ /** Snap-target builder closure supplied by the hook (closes over refs + store). */
20
+ type BuildSnapTargets = (
21
+ excludeElementKey: string | null,
22
+ includeBeats: boolean,
23
+ ) => TimelineSnapTarget[];
24
+
25
+ export interface DragPreviewContext {
26
+ scroll: HTMLDivElement | null;
27
+ pps: number;
28
+ duration: number;
29
+ trackOrder: number[];
30
+ elements: TimelineElement[];
31
+ selectedKeys: ReadonlySet<string>;
32
+ buildSnapTargets: BuildSnapTargets;
33
+ /**
34
+ * The set of tracks that hold audio clips (drives zone-aware drop placement).
35
+ * Frozen for the whole gesture, so the hook builds it ONCE at drag start and
36
+ * passes it in — see useTimelineClipDrag. Absent (e.g. in unit tests) ⇒ built
37
+ * on demand from `elements`, so the result is identical either way.
38
+ */
39
+ audioTracks?: ReadonlySet<number>;
9
40
  }
10
41
 
11
- export interface TimelineMovePreview {
12
- start: number;
13
- track: number;
14
- previewLayerId?: TimelineLayerId;
15
- previewLayerIndex?: number;
16
- stackingReorder?: TimelineStackingReorderIntent | null;
42
+ /**
43
+ * Max start a drag may reach. Allow dragging past the current content into the
44
+ * rendered timeline extent (the viewport-fill keeps that ≥ the viewport width).
45
+ * The composition grows to fit on commit (content-driven duration), so don't
46
+ * cap at content length.
47
+ */
48
+ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duration: number): number {
49
+ return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration);
17
50
  }
18
51
 
19
- export interface TimelineGroupMovePreview {
20
- active: boolean;
21
- previewStart: number;
52
+ /**
53
+ * Rigid group move: when the grabbed clip is part of a multi-selection, the
54
+ * WHOLE formation shifts by its delta on commit (see timelineClipDragCommit).
55
+ * Clamp that delta here — against every selected member's start — so the
56
+ * grabbed clip can't out-run the group: it STOPS the instant any member would
57
+ * cross 0, exactly as it lands on commit. Lane changes still apply to the
58
+ * grabbed clip only, so only the start (x) is constrained.
59
+ */
60
+ function resolveGroupClampedStart(
61
+ snapStart: number,
62
+ element: TimelineElement,
63
+ dragKey: string,
64
+ elements: TimelineElement[],
65
+ selectedKeys: ReadonlySet<string>,
66
+ ): number {
67
+ if (selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return snapStart;
68
+ const memberStarts = elements.filter((e) => selectedKeys.has(e.key ?? e.id)).map((e) => e.start);
69
+ const clampedDelta = clampGroupMoveDelta(snapStart - element.start, memberStarts);
70
+ return element.start + clampedDelta;
22
71
  }
23
72
 
24
- export function resolveDragPreviewPlacement(
25
- drag: DragPreviewState,
26
- nextMove: TimelineMovePreview,
27
- groupMove: TimelineGroupMovePreview,
28
- ): {
73
+ /**
74
+ * The whole drop decision (no same-track overlap, zone-respecting, relocate or
75
+ * create) — one tested pure function, so what runs here is what's verified.
76
+ */
77
+ function resolveDropPlacement(
78
+ drag: DraggedClipState,
79
+ clientY: number,
80
+ previewStart: number,
81
+ desiredTrack: number,
82
+ ctx: DragPreviewContext,
83
+ ): { track: number; insertRow: number | null } {
84
+ const { scroll, trackOrder, elements } = ctx;
85
+ // rowFloat = the pointer's position in track-heights from the top lane; a
86
+ // near-boundary hover requests a deliberate new-track insert. Uses the
87
+ // shared row→y inverse so the top breathing pad is subtracted consistently.
88
+ const rowFloat = scroll
89
+ ? getTimelineRowFromY(clientY - scroll.getBoundingClientRect().top + scroll.scrollTop)
90
+ : 0;
91
+ // Geometry-exact band (the clip inset) so an insert only arms in the visible
92
+ // gutter BETWEEN clip bodies — dragging over a clip body is a lane move, never a
93
+ // phantom insert (the plain-horizontal-drag misfire). See INSERT_BOUNDARY_BAND.
94
+ const rawInsertRow = resolveInsertRow(rowFloat, trackOrder.length, INSERT_BOUNDARY_BAND);
95
+ // Pointer sub-row half: when a drop must auto-create a track (aimed span
96
+ // occupied, no free lane), open it on the side the pointer is nearer.
97
+ const preferInsertAbove = rowFloat - Math.floor(rowFloat) < 0.5;
98
+ const audioTracks =
99
+ ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
100
+ return resolveZoneDropPlacement({
101
+ order: trackOrder,
102
+ audioTracks,
103
+ elements,
104
+ desiredTrack,
105
+ deliberateInsertRow: rawInsertRow,
106
+ start: previewStart,
107
+ duration: drag.element.duration,
108
+ dragKey: drag.element.key ?? drag.element.id,
109
+ isAudio: isAudioTimelineElement(drag.element),
110
+ preferInsertAbove,
111
+ });
112
+ }
113
+
114
+ /** Recompute the dragged-clip preview (move + snap + group clamp + drop placement). */
115
+ export function computeDragPreview(
116
+ drag: DraggedClipState,
117
+ clientX: number,
118
+ clientY: number,
119
+ ctx: DragPreviewContext,
120
+ ): DraggedClipState {
121
+ const { scroll, pps, duration, trackOrder, elements, selectedKeys, buildSnapTargets } = ctx;
122
+ const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
123
+ const nextMove = resolveTimelineMove(
124
+ {
125
+ start: drag.element.start,
126
+ track: drag.element.track,
127
+ duration: drag.element.duration,
128
+ originClientX: drag.originClientX,
129
+ originClientY: drag.originClientY,
130
+ originScrollLeft: drag.originScrollLeft,
131
+ originScrollTop: drag.originScrollTop,
132
+ currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
133
+ currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
134
+ pixelsPerSecond: pps,
135
+ trackHeight: TRACK_H,
136
+ maxStart: dragMaxStart,
137
+ trackOrder,
138
+ },
139
+ clientX,
140
+ clientY,
141
+ );
142
+ // The music track defines the beats, so it must not snap to them —
143
+ // but it still snaps to the playhead and other clip edges.
144
+ const targets = buildSnapTargets(
145
+ drag.element.key ?? drag.element.id,
146
+ !isMusicTrack(drag.element),
147
+ );
148
+ const snap = snapMoveToTargets(
149
+ nextMove.start,
150
+ drag.element.duration,
151
+ targets,
152
+ pps,
153
+ // Relaxed clamp: allow the snapped start past the content, up to the
154
+ // rendered extent (see dragMaxStart) — the composition grows on commit.
155
+ dragMaxStart + drag.element.duration,
156
+ );
157
+ const dragKey = drag.element.key ?? drag.element.id;
158
+ const previewStart = resolveGroupClampedStart(
159
+ snap.start,
160
+ drag.element,
161
+ dragKey,
162
+ elements,
163
+ selectedKeys,
164
+ );
165
+ const { track: previewTrack, insertRow } = resolveDropPlacement(
166
+ drag,
167
+ clientY,
168
+ previewStart,
169
+ nextMove.track,
170
+ ctx,
171
+ );
172
+ return {
173
+ ...drag,
174
+ started: true,
175
+ pointerClientX: clientX,
176
+ pointerClientY: clientY,
177
+ previewStart,
178
+ previewTrack,
179
+ // The lane the POINTER aims at (pre-collision): the commit reads it to tell a
180
+ // deliberate vertical lane change from a horizontal drag merely bumped sideways.
181
+ desiredTrack: nextMove.track,
182
+ insertRow,
183
+ snapTime: snap.snapTime,
184
+ snapType: snap.snapType,
185
+ };
186
+ }
187
+
188
+ export interface ResizePreviewContext {
189
+ scroll: HTMLDivElement | null;
190
+ pps: number;
191
+ buildSnapTargets: BuildSnapTargets;
192
+ }
193
+
194
+ export interface ResizePreviewResult {
195
+ originScrollLeft: number;
29
196
  previewStart: number;
30
- previewTrack: number;
31
- previewLayerId: TimelineLayerId;
32
- previewLayerIndex: number;
33
- previewStackingReorder: TimelineStackingReorderIntent | null;
34
- } {
35
- if (groupMove.active) {
36
- return {
37
- previewStart: groupMove.previewStart,
38
- previewTrack: drag.element.track,
39
- previewLayerId: drag.previewLayerId,
40
- previewLayerIndex: drag.previewLayerIndex,
41
- previewStackingReorder: null,
42
- };
197
+ previewDuration: number;
198
+ previewPlaybackStart?: number;
199
+ }
200
+
201
+ /** Compute the trim preview for a pointer x (pure — the hook applies the state). */
202
+ // fallow-ignore-next-line complexity
203
+ export function computeResizePreview(
204
+ resize: ResizingClipState,
205
+ clientX: number,
206
+ ctx: ResizePreviewContext,
207
+ ): ResizePreviewResult {
208
+ const { scroll, pps, buildSnapTargets } = ctx;
209
+ // Scroll compensation: auto-scroll moves the content while the pointer stays
210
+ // put, so fold the scroll delta into the pointer x (mirrors
211
+ // resolveTimelineMove's originScrollLeft handling).
212
+ const originScrollLeft = resize.originScrollLeft ?? scroll?.scrollLeft ?? 0;
213
+ const effectiveClientX = clientX + ((scroll?.scrollLeft ?? originScrollLeft) - originScrollLeft);
214
+
215
+ const sourceRemaining =
216
+ resize.element.sourceDuration != null
217
+ ? Math.max(
218
+ 0,
219
+ (resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) /
220
+ Math.max(resize.element.playbackRate ?? 1, 0.1),
221
+ )
222
+ : Number.POSITIVE_INFINITY;
223
+ const normalizedTag = resize.element.tag.toLowerCase();
224
+ const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
225
+ const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
226
+ // Trim limit = available source media only — NOT the composition length.
227
+ // Duration is content-driven (the comp grows/shrinks to fit on commit), so
228
+ // capping a trim at the current comp end both blocked extending the last clip
229
+ // rightward and, after a far move, collapsed a clip to the sliver between its
230
+ // start and the comp end (the 8s→0.95s audio incident). Images/text/shapes
231
+ // have no source, so they extend freely.
232
+ const maxEnd = resize.element.start + sourceRemaining;
233
+ let nextResize = resolveTimelineResize(
234
+ {
235
+ start: resize.element.start,
236
+ duration: resize.element.duration,
237
+ originClientX: resize.originClientX,
238
+ pixelsPerSecond: pps,
239
+ minStart: 0,
240
+ maxEnd,
241
+ playbackStart:
242
+ resize.edge === "start" && canSeedPlaybackStart
243
+ ? (resize.element.playbackStart ?? 0)
244
+ : resize.element.playbackStart,
245
+ playbackRate: resize.element.playbackRate,
246
+ },
247
+ resize.edge,
248
+ effectiveClientX,
249
+ );
250
+
251
+ // Snap edge to unified targets (beats + clip edges + playhead) when available.
252
+ // The snap must stay inside the same limits resolveTimelineResize enforces, or
253
+ // it would push the edge past the available source media / composition end.
254
+ // The music track defines the beats, so it must not snap to them — but it
255
+ // still snaps to the playhead and other clip edges.
256
+ const trimTargets = buildSnapTargets(
257
+ resize.element.key ?? resize.element.id,
258
+ !isMusicTrack(resize.element),
259
+ );
260
+ if (trimTargets.length > 0) {
261
+ const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1);
262
+ if (resize.edge === "end") {
263
+ const edgeTime = nextResize.start + nextResize.duration;
264
+ const snapped = snapTimelineTime(edgeTime, trimTargets, snapSecs).time;
265
+ // Stay within [start+minDuration, maxEnd] so the snap can't create a
266
+ // degenerate clip or run past the source/composition limit.
267
+ const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
268
+ if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
269
+ nextResize = { ...nextResize, duration: snappedDuration };
270
+ }
271
+ } else {
272
+ const snapped = snapTimelineTime(nextResize.start, trimTargets, snapSecs).time;
273
+ const delta = nextResize.start - snapped; // >0 when snapping left
274
+ // Leftward snap reveals more source; cap so playbackStart can't go < 0.
275
+ const maxLeftDelta =
276
+ nextResize.playbackStart != null
277
+ ? nextResize.playbackStart / playbackRate
278
+ : Number.POSITIVE_INFINITY;
279
+ // Also require the resulting duration to stay >= minDuration so a rightward
280
+ // snap (delta < 0) can't collapse the clip to zero/negative.
281
+ const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
282
+ if (
283
+ snapped !== nextResize.start &&
284
+ snapped >= 0 &&
285
+ delta <= maxLeftDelta + 1e-6 &&
286
+ snappedDuration >= 0.05
287
+ ) {
288
+ nextResize = {
289
+ ...nextResize,
290
+ start: snapped,
291
+ duration: snappedDuration,
292
+ playbackStart:
293
+ nextResize.playbackStart != null
294
+ ? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
295
+ 1000
296
+ : undefined,
297
+ };
298
+ }
299
+ }
43
300
  }
44
301
 
45
302
  return {
46
- previewStart: groupMove.previewStart,
47
- previewTrack: nextMove.track,
48
- previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId,
49
- previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex,
50
- previewStackingReorder: nextMove.stackingReorder ?? null,
303
+ originScrollLeft,
304
+ previewStart: nextResize.start,
305
+ previewDuration: nextResize.duration,
306
+ previewPlaybackStart: nextResize.playbackStart,
51
307
  };
52
308
  }
309
+
310
+ /**
311
+ * Apply a rigid group-resize preview: fold the grabbed clip's raw delta into the
312
+ * session, preview every non-grabbed member through the store (`updateElement`),
313
+ * and set the grabbed clip's preview state (it renders from resizingClip state, so
314
+ * its store value stays pristine until commit — like the single-clip path).
315
+ */
316
+ export function previewGroupResize(
317
+ session: TimelineGroupResizeSession,
318
+ next: ResizePreviewResult,
319
+ grabbedKey: string,
320
+ updateElement: (
321
+ key: string,
322
+ patch: { start: number; duration: number; playbackStart?: number },
323
+ ) => void,
324
+ setResizeState: (v: ResizePreviewResult) => void,
325
+ ): void {
326
+ const grabbedChange = applyTimelineGroupResizePreview(session, next);
327
+ for (const c of session.changes) {
328
+ if (c.key === grabbedKey) continue;
329
+ updateElement(c.key, { start: c.start, duration: c.duration, playbackStart: c.playbackStart });
330
+ }
331
+ setResizeState({
332
+ originScrollLeft: next.originScrollLeft,
333
+ previewStart: grabbedChange?.start ?? next.previewStart,
334
+ previewDuration: grabbedChange?.duration ?? next.previewDuration,
335
+ previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart,
336
+ });
337
+ }
@@ -0,0 +1,65 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import type { TimelineSnapType } from "./timelineSnapping";
3
+ import type { BlockedTimelineEditIntent } from "./timelineEditing";
4
+
5
+ /* ── Shared clip-drag state types ───────────────────────────────── */
6
+ export interface DraggedClipState {
7
+ element: TimelineElement;
8
+ originClientX: number;
9
+ originClientY: number;
10
+ originScrollLeft: number;
11
+ originScrollTop: number;
12
+ pointerClientX: number;
13
+ pointerClientY: number;
14
+ pointerOffsetX: number;
15
+ pointerOffsetY: number;
16
+ previewStart: number;
17
+ previewTrack: number;
18
+ /**
19
+ * The lane the POINTER aims at (from the drag's vertical position), before
20
+ * collision resolution. `previewTrack` is where the clip actually LANDS —
21
+ * which may differ from `desiredTrack` when the aimed span is occupied and the
22
+ * collision rules bump the dragged clip to a free lane. The commit reads this
23
+ * to tell a deliberate VERTICAL lane change (pointer aimed at another lane →
24
+ * stacking sync allowed) from a plain HORIZONTAL drag whose clip merely got
25
+ * bumped sideways (never touches z). Optional: when absent the commit falls
26
+ * back to `previewTrack` (pre-existing behaviour).
27
+ */
28
+ desiredTrack?: number;
29
+ /**
30
+ * When non-null, the drop inserts a NEW track at this visual row boundary
31
+ * (0 = above the top lane, trackOrder.length = below the bottom) instead of
32
+ * landing on previewTrack. Drives the insertion-line indicator.
33
+ */
34
+ insertRow: number | null;
35
+ /** Snap target the clip will land on, for the guide highlight. */
36
+ snapTime: number | null;
37
+ snapType: TimelineSnapType | null;
38
+ started: boolean;
39
+ }
40
+
41
+ export interface ResizingClipState {
42
+ element: TimelineElement;
43
+ edge: "start" | "end";
44
+ originClientX: number;
45
+ /**
46
+ * scrollLeft at gesture start. Edge auto-scroll moves the content under a
47
+ * stationary pointer, so the trim math folds (current − origin) scrollLeft
48
+ * into the pointer x — mirroring resolveTimelineMove's scroll compensation.
49
+ * Optional so pre-existing constructors/tests stay valid; when absent the
50
+ * first preview update captures the current scrollLeft.
51
+ */
52
+ originScrollLeft?: number;
53
+ previewStart: number;
54
+ previewDuration: number;
55
+ previewPlaybackStart?: number;
56
+ started: boolean;
57
+ }
58
+
59
+ export interface BlockedClipState {
60
+ element: TimelineElement;
61
+ intent: BlockedTimelineEditIntent;
62
+ originClientX: number;
63
+ originClientY: number;
64
+ started: boolean;
65
+ }
@@ -9,6 +9,7 @@ import {
9
9
  resolveZoneDropPlacement,
10
10
  timeRangesOverlap,
11
11
  } from "./timelineCollision";
12
+ import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
12
13
 
13
14
  function el(id: string, track: number, start: number, duration: number): TimelineElement {
14
15
  return { id, tag: "video", start, duration, track };
@@ -191,8 +192,47 @@ describe("resolveInsertRow", () => {
191
192
  });
192
193
  });
193
194
 
194
- // (The production insert-band suite imports INSERT_BOUNDARY_BAND from timelineLayout,
195
- // which lands with the timeline glue swap the full suite re-lands there.)
195
+ describe("resolveInsertRow production band only arms in the inter-clip gutter (UX rule 1)", () => {
196
+ // The production band equals the clip inset (CLIP_Y / TRACK_H): a clip body fills
197
+ // [band, 1 − band] of its row, so an insert must ONLY arm in the thin gutter that
198
+ // straddles a boundary — never while the pointer is over a clip body. This is the
199
+ // regression for the plain-horizontal-drag misfire (the old 0.32 band armed an
200
+ // insert across ~64% of every row).
201
+ const n = 3;
202
+ const b = INSERT_BOUNDARY_BAND;
203
+
204
+ it("the band is the clip inset (3/48), not the old feel-tuned 0.32", () => {
205
+ expect(b).toBeCloseTo(3 / 48, 10);
206
+ expect(b).toBeLessThan(0.1);
207
+ });
208
+
209
+ it("returns null across the WHOLE clip body of every lane (no insert = move-to-lane)", () => {
210
+ // Sweep each lane's body [band+ε, 1−band−ε] in fine steps → always a move.
211
+ for (let lane = 0; lane < n; lane++) {
212
+ for (let frac = b + 0.01; frac <= 1 - b - 0.01 + 1e-9; frac += 0.02) {
213
+ expect(resolveInsertRow(lane + frac, n, b)).toBeNull();
214
+ }
215
+ }
216
+ });
217
+
218
+ it("arms an insert in the gutter straddling every internal boundary (dead-zone-free)", () => {
219
+ // Just under a boundary → insert BELOW the upper lane; just over → ABOVE the
220
+ // lower lane. Both resolve to the same boundary row, so the gutter has no dead
221
+ // spot that neither moves nor inserts.
222
+ expect(resolveInsertRow(1 - b / 2, n, b)).toBe(1); // bottom gutter of lane 0
223
+ expect(resolveInsertRow(1 + b / 2, n, b)).toBe(1); // top gutter of lane 1
224
+ expect(resolveInsertRow(2 - b / 2, n, b)).toBe(2);
225
+ expect(resolveInsertRow(2 + b / 2, n, b)).toBe(2);
226
+ });
227
+
228
+ it("still arms a top / bottom insert above the first / below the last lane", () => {
229
+ expect(resolveInsertRow(b / 2, n, b)).toBe(0); // top gutter of lane 0 → insert above top
230
+ expect(resolveInsertRow(-0.4, n, b)).toBe(0); // in the top breathing pad
231
+ expect(resolveInsertRow(n - b / 2, n, b)).toBe(n); // bottom gutter of last lane
232
+ expect(resolveInsertRow(n + 0.4, n, b)).toBe(n); // in the bottom breathing pad
233
+ });
234
+ });
235
+
196
236
  describe("clampTrackToZone", () => {
197
237
  // trackOrder [0,1,2,3]: rows 0,1 = visual; rows 2,3 = audio (audioRow = 2).
198
238
  const order = [0, 1, 2, 3];
@@ -1,23 +1,50 @@
1
1
  import { useCallback, useState, type RefObject } from "react";
2
2
  import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
3
+ import { usePlayerStore } from "../store/playerStore";
3
4
  import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
4
5
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
5
- import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
6
6
 
7
7
  interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
8
8
  scrollRef: RefObject<HTMLDivElement | null>;
9
9
  ppsRef: RefObject<number>;
10
10
  durationRef: RefObject<number>;
11
- trackOrderRef: RefObject<TimelineLayerId[]>;
12
- timelineLayersRef: RefObject<StackingTimelineLayer[]>;
11
+ trackOrderRef: RefObject<number[]>;
13
12
  }
14
13
 
14
+ type TimelinePlacement = { start: number; track: number };
15
+
16
+ /**
17
+ * Parse a JSON drag payload and, if it yields a value, forward it to the drop
18
+ * callback. Malformed payloads are ignored. Shared by the asset + block paths so
19
+ * the parse/guard/dispatch shape lives in one place.
20
+ */
21
+ function applyJsonDropPayload(
22
+ raw: string,
23
+ pick: (parsed: Record<string, string | undefined>) => string | undefined,
24
+ apply: (value: string, placement: TimelinePlacement) => void,
25
+ placement: TimelinePlacement,
26
+ ): void {
27
+ try {
28
+ const value = pick(JSON.parse(raw) as Record<string, string | undefined>);
29
+ if (value) apply(value, placement);
30
+ } catch {
31
+ /* ignore malformed drag payloads */
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
37
+ * start is the current playhead time, only the track comes from the drop y.
38
+ * Deliberate product choice (user preference, 2026-07-09): every add lands at
39
+ * the playhead regardless of drop x, like CapCut's add-to-timeline. External
40
+ * OS file drops and internal asset drops share this same placement path, so
41
+ * both land identically.
42
+ */
15
43
  export function useTimelineAssetDrop({
16
44
  scrollRef,
17
45
  ppsRef,
18
46
  durationRef,
19
47
  trackOrderRef,
20
- timelineLayersRef,
21
48
  onFileDrop,
22
49
  onAssetDrop,
23
50
  onBlockDrop,
@@ -25,83 +52,65 @@ export function useTimelineAssetDrop({
25
52
  const [isDragOver, setIsDragOver] = useState(false);
26
53
 
27
54
  const handleAssetDragOver = useCallback((e: React.DragEvent) => {
28
- const hasFiles = e.dataTransfer.files.length > 0;
29
55
  const types = Array.from(e.dataTransfer.types);
56
+ const hasFiles = types.includes("Files");
30
57
  const hasAsset = types.includes(TIMELINE_ASSET_MIME);
31
58
  const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
32
59
  if (!hasFiles && !hasAsset && !hasBlock) return;
33
60
  e.preventDefault();
34
- if (hasAsset || hasBlock) e.dataTransfer.dropEffect = "copy";
61
+ e.dataTransfer.dropEffect = "copy";
35
62
  setIsDragOver(true);
36
63
  }, []);
37
64
 
65
+ const clearDropPreview = useCallback(() => setIsDragOver(false), []);
66
+
67
+ const resolveDropPlacement = useCallback(
68
+ (clientX: number, clientY: number): TimelinePlacement => {
69
+ const scroll = scrollRef.current;
70
+ const rect = scroll?.getBoundingClientRect();
71
+ // Track comes from the vertical drop position; start is the playhead.
72
+ const { track } = resolveTimelineAssetDrop(
73
+ {
74
+ rectLeft: rect?.left ?? 0,
75
+ rectTop: rect?.top ?? 0,
76
+ scrollLeft: scroll?.scrollLeft ?? 0,
77
+ scrollTop: scroll?.scrollTop ?? 0,
78
+ pixelsPerSecond: ppsRef.current,
79
+ duration: durationRef.current,
80
+ trackHeight: TRACK_H,
81
+ trackOrder: trackOrderRef.current,
82
+ },
83
+ clientX,
84
+ clientY,
85
+ );
86
+ const start = Math.max(0, usePlayerStore.getState().currentTime);
87
+ return { start, track };
88
+ },
89
+ [scrollRef, ppsRef, durationRef, trackOrderRef],
90
+ );
91
+
38
92
  const handleAssetDrop = useCallback(
39
- // fallow-ignore-next-line complexity
40
93
  (e: React.DragEvent) => {
41
94
  e.preventDefault();
42
95
  setIsDragOver(false);
43
- const scroll = scrollRef.current;
44
- const rect = scroll?.getBoundingClientRect();
45
- const layerById = new Map(timelineLayersRef.current.map((layer) => [layer.id, layer]));
46
- const trackOrder = trackOrderRef.current
47
- .map((id) => layerById.get(id)?.placementTrack)
48
- .filter((track): track is number => track != null);
49
- const dropInput = {
50
- rectLeft: rect?.left ?? 0,
51
- rectTop: rect?.top ?? 0,
52
- scrollLeft: scroll?.scrollLeft ?? 0,
53
- scrollTop: scroll?.scrollTop ?? 0,
54
- pixelsPerSecond: ppsRef.current,
55
- duration: durationRef.current,
56
- trackHeight: TRACK_H,
57
- trackOrder,
58
- };
96
+ const placement = resolveDropPlacement(e.clientX, e.clientY);
97
+
59
98
  if (onFileDrop && e.dataTransfer.files.length > 0) {
60
- void onFileDrop(
61
- Array.from(e.dataTransfer.files),
62
- scroll && rect ? resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY) : undefined,
63
- );
99
+ void onFileDrop(Array.from(e.dataTransfer.files), placement);
64
100
  return;
65
101
  }
66
102
  const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
67
- if (assetPayload && onAssetDrop && scroll && rect) {
68
- try {
69
- const parsed = JSON.parse(assetPayload) as { path?: string };
70
- if (parsed.path)
71
- void onAssetDrop(
72
- parsed.path,
73
- resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
74
- );
75
- } catch {
76
- /* ignore malformed drag payloads */
77
- }
103
+ if (assetPayload && onAssetDrop) {
104
+ applyJsonDropPayload(assetPayload, (p) => p.path, onAssetDrop, placement);
78
105
  return;
79
106
  }
80
107
  const blockPayload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
81
- if (blockPayload && onBlockDrop && scroll && rect) {
82
- try {
83
- const parsed = JSON.parse(blockPayload) as { name?: string };
84
- if (parsed.name)
85
- void onBlockDrop(
86
- parsed.name,
87
- resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
88
- );
89
- } catch {
90
- /* ignore malformed drag payloads */
91
- }
108
+ if (blockPayload && onBlockDrop) {
109
+ applyJsonDropPayload(blockPayload, (p) => p.name, onBlockDrop, placement);
92
110
  }
93
111
  },
94
- [
95
- onAssetDrop,
96
- onBlockDrop,
97
- onFileDrop,
98
- scrollRef,
99
- ppsRef,
100
- durationRef,
101
- trackOrderRef,
102
- timelineLayersRef,
103
- ],
112
+ [resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop],
104
113
  );
105
114
 
106
- return { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop };
115
+ return { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview };
107
116
  }