@hyperframes/studio 0.7.54 → 0.7.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,263 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+
3
+ /**
4
+ * Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
5
+ * the rows ABOVE the first audio lane; audio clips stay AT/BELOW it. Prevents a
6
+ * clip from appearing to land in the wrong zone mid-drag (which normalizeToZones
7
+ * would then snap back). `audioRow` = index in `trackOrder` of the first audio
8
+ * lane, or -1 when there is no audio zone yet (then it's a no-op).
9
+ */
10
+ export function clampTrackToZone(
11
+ targetTrack: number,
12
+ trackOrder: number[],
13
+ audioRow: number,
14
+ isAudio: boolean,
15
+ ): number {
16
+ if (audioRow < 0) return targetTrack;
17
+ const row = trackOrder.indexOf(targetTrack);
18
+ if (row < 0) return targetTrack;
19
+ if (isAudio) return row >= audioRow ? targetTrack : (trackOrder[audioRow] ?? targetTrack);
20
+ return row < audioRow ? targetTrack : (trackOrder[audioRow - 1] ?? targetTrack);
21
+ }
22
+
23
+ /**
24
+ * Whether a new-track insert at boundary `insertRow` is allowed for a clip of the
25
+ * given kind. Visual clips may only insert visual lanes (boundary at/above the top
26
+ * of the audio zone); audio clips may only insert audio lanes (boundary at/below
27
+ * it) — so audio clips CAN create a new audio track, and neither kind inserts into
28
+ * the other's zone. `audioRow` = first audio lane row, or -1 (no audio zone) → any.
29
+ */
30
+ export function isInsertAllowedForZone(
31
+ insertRow: number,
32
+ audioRow: number,
33
+ isAudio: boolean,
34
+ ): boolean {
35
+ if (audioRow < 0) return true;
36
+ return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
37
+ }
38
+
39
+ /**
40
+ * The full drop-placement decision for a dragged clip — one pure, testable unit.
41
+ * Enforces: NO time-overlap on a single track; a clip stays in its kind-zone;
42
+ * a new track is created only when needed. Order of resolution:
43
+ * 1. Deliberate boundary insert (pointer near a lane edge), if it's in the
44
+ * clip's own zone → create a new track there.
45
+ * 2. Otherwise land on a lane: clamp the aimed track to the clip's zone, take it
46
+ * if free at [start, start+duration), else the nearest FREE lane in the zone
47
+ * (prefer up), else auto-create a new track right below the aimed lane.
48
+ * `audioTracks` = the set of track indices that currently hold audio (so the fn
49
+ * needs no element-kind import). Returns the landing `track` and, when a new track
50
+ * should be created, the `insertRow` boundary (else null).
51
+ *
52
+ * `preferInsertAbove` biases the auto-created track (occupied-aim → new adjacent
53
+ * track) toward the boundary ABOVE the aimed row instead of below it, so the new
54
+ * lane opens on whichever side of the aimed clip the pointer is nearer (the drag
55
+ * preview passes the pointer's sub-row half). A clip whose aimed span is occupied
56
+ * never snaps back to its origin — it relocates to a free lane, or (none free)
57
+ * gets a fresh track next to the aim. Default (below) preserves prior behaviour.
58
+ */
59
+ /**
60
+ * Insert-row boundary for an out-of-range aim — a `desired` track that isn't a
61
+ * real lane: the sentinel minTrack-1 an upward create-drag emits (#2214-adjacent
62
+ * repro) or a beyond-the-bottom index a downward one does. Anchors the new track
63
+ * to a boundary of the clip's OWN kind-zone so a visual insert can never land
64
+ * past the audio zone (the old below = order.length fallback dropped it BELOW the
65
+ * audio lanes). Above the zone (`desired` < the zone's min lane) → the zone's TOP
66
+ * boundary; otherwise → its BOTTOM boundary (for a visual clip, the top of the
67
+ * audio zone). `zoneTracks` = this kind's lanes, in `order` sequence.
68
+ */
69
+ function outOfRangeZoneInsertRow(
70
+ order: number[],
71
+ zoneTracks: number[],
72
+ audioRow: number,
73
+ desired: number,
74
+ ): number {
75
+ // No lane of this kind yet: fall to the split (audioRow) or the very top.
76
+ // A visual-only timeline has audioRow -1 (top); an all-audio one has it at 0.
77
+ if (zoneTracks.length === 0) return audioRow < 0 ? 0 : audioRow;
78
+ // zoneTracks preserves `order` sequence, so its ends map to the zone boundary
79
+ // rows: above the zone's min lane → its top boundary, else its bottom.
80
+ const zoneTop = order.indexOf(zoneTracks[0]);
81
+ const zoneBottom = order.indexOf(zoneTracks[zoneTracks.length - 1]) + 1;
82
+ return desired < Math.min(...zoneTracks) ? zoneTop : zoneBottom;
83
+ }
84
+
85
+ export function resolveZoneDropPlacement(input: {
86
+ order: number[];
87
+ audioTracks: ReadonlySet<number>;
88
+ elements: TimelineElement[];
89
+ desiredTrack: number;
90
+ deliberateInsertRow: number | null;
91
+ start: number;
92
+ duration: number;
93
+ dragKey: string;
94
+ isAudio: boolean;
95
+ preferInsertAbove?: boolean;
96
+ }): { track: number; insertRow: number | null } {
97
+ const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
98
+ const { start, duration, dragKey, isAudio, preferInsertAbove } = input;
99
+ const audioRow = order.findIndex((t) => audioTracks.has(t));
100
+
101
+ if (
102
+ deliberateInsertRow !== null &&
103
+ isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
104
+ ) {
105
+ return { track: desiredTrack, insertRow: deliberateInsertRow };
106
+ }
107
+
108
+ const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
109
+ const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
110
+ const placement = resolvePlacement({
111
+ elements,
112
+ desiredTrack: desired,
113
+ start,
114
+ duration,
115
+ trackOrder: zoneTracks,
116
+ excludeKey: dragKey,
117
+ });
118
+ if (placement.needsInsert) {
119
+ const desiredRow = order.indexOf(desired);
120
+ if (desiredRow < 0) {
121
+ return {
122
+ track: desired,
123
+ insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
124
+ };
125
+ }
126
+ // Prefer the gap NEAREST the pointer: insert above the aimed row when the
127
+ // pointer sits in its upper half AND that boundary is in the clip's own zone
128
+ // (else the visual/audio split would be crossed) — otherwise fall to below.
129
+ // `desired` is clamped into the zone, so both boundaries stay in-zone.
130
+ const insertRow =
131
+ preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio)
132
+ ? desiredRow
133
+ : desiredRow + 1;
134
+ return { track: desired, insertRow };
135
+ }
136
+ return { track: placement.track, insertRow: null };
137
+ }
138
+
139
+ /**
140
+ * Fallback half-width (fraction of a track height) of the insert band straddling
141
+ * a lane boundary — used only when the caller passes no explicit band. Production
142
+ * threads the geometry-exact `INSERT_BOUNDARY_BAND` (timelineLayout.ts, = the clip
143
+ * inset `CLIP_Y / TRACK_H`) so the band matches the rendered inter-clip gutter and
144
+ * NEVER reaches into a clip body. Kept in sync with that constant; do not widen it
145
+ * back toward the old 0.32 (which armed an insert across ~64% of every row — the
146
+ * misfire that turned a plain horizontal drag into a phantom track insert).
147
+ */
148
+ const INSERT_BAND = 3 / 48;
149
+
150
+ /**
151
+ * Decide whether a vertical drag is inserting a new track at a lane boundary.
152
+ * `rowFloat` is the pointer's position in track-height units from the top of the
153
+ * first lane (0 = top of lane 0). Returns the boundary row to insert at
154
+ * (0 = above the top lane, `trackCount` = below the bottom), or null when the
155
+ * pointer is over a lane's middle band (a normal move/target).
156
+ */
157
+ export function resolveInsertRow(
158
+ rowFloat: number,
159
+ trackCount: number,
160
+ band: number = INSERT_BAND,
161
+ ): number | null {
162
+ if (trackCount === 0) return 0;
163
+ if (rowFloat <= 0) return 0;
164
+ if (rowFloat >= trackCount) return trackCount;
165
+ const lane = Math.floor(rowFloat);
166
+ const frac = rowFloat - lane;
167
+ if (frac < band) return lane;
168
+ if (frac > 1 - band) return lane + 1;
169
+ return null;
170
+ }
171
+
172
+ /** Half-open overlap test: [aStart, aEnd) intersects [bStart, bEnd). */
173
+ export function timeRangesOverlap(
174
+ aStart: number,
175
+ aEnd: number,
176
+ bStart: number,
177
+ bEnd: number,
178
+ ): boolean {
179
+ return aStart < bEnd && bStart < aEnd;
180
+ }
181
+
182
+ /**
183
+ * True when no clip on `track` overlaps [start, end) — excluding the clip
184
+ * identified by `excludeKey` (the one being dragged).
185
+ */
186
+ export function isLaneFree(
187
+ elements: TimelineElement[],
188
+ track: number,
189
+ start: number,
190
+ end: number,
191
+ excludeKey: string | null,
192
+ ): boolean {
193
+ return !elements.some(
194
+ (el) =>
195
+ (el.key ?? el.id) !== excludeKey &&
196
+ el.track === track &&
197
+ timeRangesOverlap(start, end, el.start, el.start + el.duration),
198
+ );
199
+ }
200
+
201
+ export interface PlacementInput {
202
+ elements: TimelineElement[];
203
+ desiredTrack: number;
204
+ start: number;
205
+ duration: number;
206
+ trackOrder: number[];
207
+ excludeKey: string | null;
208
+ }
209
+
210
+ export interface PlacementResult {
211
+ /** The lane the clip should land on. */
212
+ track: number;
213
+ /**
214
+ * True when no existing lane was free and the caller should insert a new
215
+ * track instead of landing on `track` (which is then the desired lane as a
216
+ * last-resort fallback). Consumed in later stages (2b/2c); stage 2a ignores it.
217
+ */
218
+ needsInsert: boolean;
219
+ }
220
+
221
+ /**
222
+ * Resolve where a dragged clip should land, avoiding overlap. If the desired
223
+ * lane is free, keep it. Otherwise search the nearest free lane, **preferring
224
+ * up** (all lanes above, nearest first), then down. If none is free, signal an
225
+ * insert and fall back to the desired lane.
226
+ */
227
+ export function resolvePlacement({
228
+ elements,
229
+ desiredTrack,
230
+ start,
231
+ duration,
232
+ trackOrder,
233
+ excludeKey,
234
+ }: PlacementInput): PlacementResult {
235
+ const end = start + duration;
236
+ const idx = trackOrder.indexOf(desiredTrack);
237
+ // desiredTrack is not one of the zone's lanes — the clip's kind-zone has no lane
238
+ // yet (e.g. an audio clip dropped on a visual-only timeline). This MUST be checked
239
+ // BEFORE the isLaneFree short-circuit below: a free-aimed span on a foreign-zone
240
+ // lane (an audio clip aimed at an empty stretch of a visual-only timeline) is
241
+ // "free" only because that lane belongs to the wrong zone. Landing there would
242
+ // put the clip in the wrong kind-zone, so signal an insert to create the zone's
243
+ // first lane instead — regardless of whether the aimed span is occupied (#2195).
244
+ if (idx === -1) return { track: desiredTrack, needsInsert: true };
245
+
246
+ if (isLaneFree(elements, desiredTrack, start, end, excludeKey)) {
247
+ return { track: desiredTrack, needsInsert: false };
248
+ }
249
+
250
+ // Prefer up: nearest lane above first, then the rest above.
251
+ for (let up = idx - 1; up >= 0; up--) {
252
+ if (isLaneFree(elements, trackOrder[up], start, end, excludeKey)) {
253
+ return { track: trackOrder[up], needsInsert: false };
254
+ }
255
+ }
256
+ // Then down: nearest lane below first.
257
+ for (let down = idx + 1; down < trackOrder.length; down++) {
258
+ if (isLaneFree(elements, trackOrder[down], start, end, excludeKey)) {
259
+ return { track: trackOrder[down], needsInsert: false };
260
+ }
261
+ }
262
+ return { track: desiredTrack, needsInsert: true };
263
+ }
@@ -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
  }
@@ -0,0 +1,52 @@
1
+ export interface TimelineEditCapabilities {
2
+ canMove: boolean;
3
+ canTrimStart: boolean;
4
+ canTrimEnd: boolean;
5
+ }
6
+
7
+ function isDeterministicTimelineWindow(input: {
8
+ tag: string;
9
+ compositionSrc?: string;
10
+ playbackStartAttr?: "media-start" | "playback-start";
11
+ sourceDuration?: number;
12
+ }): boolean {
13
+ if (input.compositionSrc || input.playbackStartAttr != null) return true;
14
+ if (
15
+ input.sourceDuration != null &&
16
+ Number.isFinite(input.sourceDuration) &&
17
+ input.sourceDuration > 0
18
+ ) {
19
+ return true;
20
+ }
21
+ return ["video", "audio", "img"].includes(input.tag.toLowerCase());
22
+ }
23
+
24
+ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: string }): boolean {
25
+ return Boolean(input.domId || input.selector);
26
+ }
27
+
28
+ export function getTimelineEditCapabilities(input: {
29
+ tag: string;
30
+ duration: number;
31
+ domId?: string;
32
+ selector?: string;
33
+ compositionSrc?: string;
34
+ playbackStart?: number;
35
+ playbackStartAttr?: "media-start" | "playback-start";
36
+ sourceDuration?: number;
37
+ timingSource?: "authored" | "implicit";
38
+ timelineLocked?: boolean;
39
+ }): TimelineEditCapabilities {
40
+ if (input.timingSource === "implicit" || input.timelineLocked) {
41
+ return { canMove: false, canTrimStart: false, canTrimEnd: false };
42
+ }
43
+
44
+ const canPatch = hasPatchableTimelineTarget(input);
45
+ const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
46
+ const hasDeterministicWindow = isDeterministicTimelineWindow(input);
47
+ return {
48
+ canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
49
+ canTrimEnd: canPatch && hasFiniteDuration,
50
+ canTrimStart: canPatch && hasFiniteDuration,
51
+ };
52
+ }
@@ -693,7 +693,7 @@ describe("buildTimelineAgentPrompt", () => {
693
693
  prompt: "Move the title later and lower the music",
694
694
  });
695
695
 
696
- expect(text).toContain("Time range: 0:01 - 0:04");
696
+ expect(text).toContain("Time range: 00:01 - 00:04");
697
697
  expect(text).toContain("#title (div)");
698
698
  expect(text).toContain("#music (audio)");
699
699
  expect(text).toContain("Move the title later and lower the music");
@@ -4,6 +4,13 @@ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrde
4
4
  import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
5
5
  import { shouldShowTimelineLayerGroupHeader } from "./TimelineLayerGroupHeader";
6
6
  import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
7
+ import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
8
+
9
+ export {
10
+ getTimelineEditCapabilities,
11
+ hasPatchableTimelineTarget,
12
+ } from "./timelineEditCapabilities";
13
+ export type { TimelineEditCapabilities } from "./timelineEditCapabilities";
7
14
 
8
15
  import {
9
16
  applyClipStartTrimDelta,
@@ -245,12 +252,6 @@ export interface TimelinePromptElement {
245
252
  track: number;
246
253
  }
247
254
 
248
- export interface TimelineEditCapabilities {
249
- canMove: boolean;
250
- canTrimStart: boolean;
251
- canTrimEnd: boolean;
252
- }
253
-
254
255
  export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
255
256
 
256
257
  export interface TimelineRangeSelection {
@@ -362,59 +363,6 @@ export function selectTimelineElementsInMarquee({
362
363
  return selected;
363
364
  }
364
365
 
365
- function isDeterministicTimelineWindow(input: {
366
- tag: string;
367
- compositionSrc?: string;
368
- playbackStartAttr?: "media-start" | "playback-start";
369
- sourceDuration?: number;
370
- }): boolean {
371
- if (input.compositionSrc) return true;
372
- if (input.playbackStartAttr != null) return true;
373
- if (
374
- input.sourceDuration != null &&
375
- Number.isFinite(input.sourceDuration) &&
376
- input.sourceDuration > 0
377
- ) {
378
- return true;
379
- }
380
- const normalizedTag = input.tag.toLowerCase();
381
- return ["video", "audio", "img"].includes(normalizedTag);
382
- }
383
-
384
- export function hasPatchableTimelineTarget(input: { domId?: string; selector?: string }): boolean {
385
- return Boolean(input.domId || input.selector);
386
- }
387
-
388
- export function getTimelineEditCapabilities(input: {
389
- tag: string;
390
- duration: number;
391
- domId?: string;
392
- selector?: string;
393
- compositionSrc?: string;
394
- playbackStart?: number;
395
- playbackStartAttr?: "media-start" | "playback-start";
396
- sourceDuration?: number;
397
- timingSource?: "authored" | "implicit";
398
- timelineLocked?: boolean;
399
- }): TimelineEditCapabilities {
400
- if (input.timingSource === "implicit" || input.timelineLocked) {
401
- return {
402
- canMove: false,
403
- canTrimStart: false,
404
- canTrimEnd: false,
405
- };
406
- }
407
-
408
- const canPatch = hasPatchableTimelineTarget(input);
409
- const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
410
- const hasDeterministicWindow = isDeterministicTimelineWindow(input);
411
- return {
412
- canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
413
- canTrimEnd: canPatch && hasFiniteDuration,
414
- canTrimStart: canPatch && hasFiniteDuration,
415
- };
416
- }
417
-
418
366
  export function resolveBlockedTimelineEditIntent(input: {
419
367
  width: number;
420
368
  offsetX: number;
@@ -525,3 +473,72 @@ export function buildTimelineElementAgentPrompt(element: {
525
473
  export function formatTimelineAttributeNumber(value: number): string {
526
474
  return Number(roundToCentiseconds(value).toFixed(2)).toString();
527
475
  }
476
+
477
+ /**
478
+ * Apply one edge auto-scroll step: scroll `scroll` toward the edge zone the
479
+ * pointer is in, clamped to the scrollable range. Returns true when the
480
+ * container actually moved (the caller keeps its RAF running and re-runs its
481
+ * live preview), false when the pointer is outside the edge zones or the scroll
482
+ * is already clamped (the caller stops).
483
+ */
484
+ export function applyTimelineAutoScrollStep(
485
+ scroll: HTMLElement,
486
+ clientX: number,
487
+ clientY: number,
488
+ ): boolean {
489
+ const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
490
+ if (delta.x === 0 && delta.y === 0) return false;
491
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
492
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
493
+ const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
494
+ const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
495
+ if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return false;
496
+ scroll.scrollLeft = nextScrollLeft;
497
+ scroll.scrollTop = nextScrollTop;
498
+ return true;
499
+ }
500
+
501
+ /**
502
+ * Decide whether an edge auto-scroll RAF loop should start, stop, or stay as-is
503
+ * for the current pointer: "start" when the pointer is in an edge zone and no
504
+ * loop is running, "stop" when it left the zones while a loop is running,
505
+ * "none" otherwise.
506
+ */
507
+ export function resolveTimelineAutoScrollLoopAction(
508
+ scroll: HTMLElement | null,
509
+ clientX: number,
510
+ clientY: number,
511
+ rafActive: boolean,
512
+ ): "start" | "stop" | "none" {
513
+ if (!scroll) return "none";
514
+ const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
515
+ if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none";
516
+ return rafActive ? "none" : "start";
517
+ }
518
+
519
+ /**
520
+ * Escape cancels an in-progress clip drag / resize / blocked-drag: no commit,
521
+ * no undo entry — the previews live only in the gesture state, so clearing it
522
+ * restores the pre-drag timeline. `suppressClick` arms the click suppressor
523
+ * only when the gesture actually started, so the click generated by the
524
+ * eventual pointerup can't reselect or split the clip.
525
+ */
526
+ export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): {
527
+ cancel: boolean;
528
+ suppressClick: boolean;
529
+ } {
530
+ if (input.key !== "Escape" || (!input.drag && !input.resize && !input.blocked)) {
531
+ return { cancel: false, suppressClick: false };
532
+ }
533
+ return {
534
+ cancel: true,
535
+ suppressClick: Boolean(input.drag?.started || input.resize?.started || input.blocked?.started),
536
+ };
537
+ }
538
+
539
+ export interface TimelineDragEscapeInput {
540
+ key: string;
541
+ drag: { started: boolean } | null;
542
+ resize: { started: boolean } | null;
543
+ blocked: { started: boolean } | null;
544
+ }