@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
@@ -0,0 +1,498 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import type { DraggedClipState } from "./useTimelineClipDrag";
3
+ import { classifyZone, normalizeToZones } from "./timelineZones";
4
+ import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
5
+ import { getTimelineEditCapabilities } from "./timelineEditing";
6
+ import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
7
+ import {
8
+ beginTimelineOptimisticGesture,
9
+ isLatestTimelineOptimisticGesture,
10
+ } from "./timelineOptimisticRevision";
11
+
12
+ type StartTrack = Pick<TimelineElement, "start" | "track">;
13
+ export interface TimelineMoveEdit {
14
+ element: TimelineElement;
15
+ updates: StartTrack;
16
+ /**
17
+ * File-space track override for the persist. The store's `updates.track` is a
18
+ * DISPLAY lane; when the source file's numbering is sparse (authored tracks
19
+ * 1,2,... or gaps), the file write must target the lane's AUTHORED track or it
20
+ * silently re-targets the wrong row. Omitted → persist `updates.track` as-is.
21
+ */
22
+ persistTrack?: number;
23
+ }
24
+
25
+ export interface DragCommitDeps {
26
+ elements: TimelineElement[];
27
+ trackOrder: number[];
28
+ updateElement: (key: string, updates: Partial<TimelineElement>) => void;
29
+ /** Single-clip, SDK-cutover-aware persist (pure time-moves keep this path). */
30
+ onMoveElement?: (element: TimelineElement, updates: StartTrack) => Promise<void> | void;
31
+ /** Atomic multi-clip persist (single undo) for lane changes + track inserts.
32
+ * `coalesceKey`, when supplied, tags the resulting "Move timeline clips"
33
+ * history entry so it merges with the lane change's z-reorder entry (see the
34
+ * lane-change branch below). */
35
+ onMoveElements?: (
36
+ edits: TimelineMoveEdit[],
37
+ coalesceKey?: string,
38
+ operation?: TimelineMoveOperation,
39
+ ) => Promise<void> | void;
40
+ /**
41
+ * The current multi-selection (store.selectedElementIds). When the dragged
42
+ * clip is part of a multi-selection (size > 1), the WHOLE selection moves by
43
+ * the dragged clip's time delta — the standard NLE gesture. Track changes
44
+ * apply to the dragged clip only; the others keep their lanes.
45
+ */
46
+ selectedKeys?: ReadonlySet<string> | null;
47
+ /**
48
+ * Lane ↔ stacking unification. When a DELIBERATE vertical lane change happens,
49
+ * the edited clip(s) get z-index patches so their canvas stacking matches lane
50
+ * order (higher lane = on top) relative to time-overlapping clips — see
51
+ * timelineStackingSync. Both deps must be supplied to engage; if either is
52
+ * absent the z-sync is skipped (pure time-moves and horizontal collision bumps
53
+ * never restack). `readZIndex` returns the clip's current z-index (from the
54
+ * live DOM inline style / computed; "auto" ⇒ 0).
55
+ */
56
+ readZIndex?: (element: TimelineElement) => number;
57
+ /**
58
+ * Apply the computed z-index patches. Wiring (in the drag hook, which owns the
59
+ * DOM/persist plumbing) forwards these to the SAME atomic style-patch persist
60
+ * the canvas z-order commit uses (handleDomZIndexReorderCommit). Documented in
61
+ * research/STAGE3-NEEDED-WIRING.md.
62
+ */
63
+ onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => void;
64
+ }
65
+
66
+ const keyOf = (e: TimelineElement) => e.key ?? e.id;
67
+ const round3 = (v: number) => Math.round(v * 1000) / 1000;
68
+
69
+ // One coalesce key per lane-change gesture, shared by the move-persist history
70
+ // entry ("Move timeline clips") and the follow-up z-reorder entry ("Reorder
71
+ // layers") so editHistory (pushEditHistoryEntry) folds the two consecutive
72
+ // records into a single undo step. A monotonic counter — NOT Date.now() /
73
+ // Math.random(), which the determinism rules forbid — suffices: the key only has
74
+ // to be unique per gesture and identical across the gesture's two records.
75
+ let laneChangeGestureSeq = 0;
76
+
77
+ /** Whether Studio may write timing to this clip (false for locked/implicit rows). */
78
+ function canMoveElement(element: TimelineElement): boolean {
79
+ return getTimelineEditCapabilities({
80
+ tag: element.tag,
81
+ duration: element.duration,
82
+ domId: element.domId,
83
+ selector: element.selector,
84
+ compositionSrc: element.compositionSrc,
85
+ playbackStart: element.playbackStart,
86
+ playbackStartAttr: element.playbackStartAttr,
87
+ sourceDuration: element.sourceDuration,
88
+ timingSource: element.timingSource,
89
+ timelineLocked: element.timelineLocked,
90
+ }).canMove;
91
+ }
92
+
93
+ /**
94
+ * Optimistically apply + persist a batch of moves with rollback on failure.
95
+ *
96
+ * Returns a promise that resolves `true` once the write lands, or `false` after a
97
+ * rejected write has been rolled back. The caller uses this to SERIALIZE the
98
+ * lane→z stacking patch: the z-sync is a separate server style-patch, and firing
99
+ * it before this full-file write resolves let the move (computed from a pre-z
100
+ * snapshot) land after — and clobber — the z change. A failed move resolves
101
+ * `false` so the caller also skips the z-sync (no orphaned z patch).
102
+ *
103
+ * The DOM is updated synchronously up front; the returned promise never rejects.
104
+ */
105
+ function persistMoveEdits(
106
+ edits: TimelineMoveEdit[],
107
+ deps: DragCommitDeps,
108
+ coalesceKey?: string,
109
+ operation: TimelineMoveOperation = "timing",
110
+ ): Promise<boolean> {
111
+ if (edits.length === 0) return Promise.resolve(true);
112
+ const { updateElement, onMoveElement, onMoveElements } = deps;
113
+ if (!onMoveElements) {
114
+ console.warn(
115
+ onMoveElement
116
+ ? `[Timeline] persistMoveEdits: only single-clip onMoveElement wired — this ${edits.length}-clip move degrades to a per-clip persist race (no atomic single-undo)`
117
+ : `[Timeline] persistMoveEdits: no move persist handler wired — ${edits.length} edit(s) applied to the store only, not saved`,
118
+ );
119
+ }
120
+ const prev = edits.map((e) => ({
121
+ key: keyOf(e.element),
122
+ start: e.element.start,
123
+ track: e.element.track,
124
+ authoredTrack: e.element.authoredTrack,
125
+ }));
126
+ const revision = beginTimelineOptimisticGesture(
127
+ updateElement,
128
+ edits.map((edit) => keyOf(edit.element)),
129
+ );
130
+ // The file write below targets `persistTrack` (authored space) when supplied,
131
+ // or `updates.track` on a genuine lane write (track insert renumber). Mirror
132
+ // that written value into the store's `authoredTrack` so a SECOND drag before
133
+ // any reload resolves authored tracks from what the file now says, not stale
134
+ // pre-edit data. Pure time-moves leave authoredTrack untouched.
135
+ for (const e of edits) {
136
+ const writtenTrack =
137
+ e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
138
+ updateElement(
139
+ keyOf(e.element),
140
+ writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
141
+ );
142
+ }
143
+ // The store above gets DISPLAY lanes; the file below gets the authored-space
144
+ // track when one was resolved (see TimelineMoveEdit.persistTrack).
145
+ const persistEdits = edits.map((e) =>
146
+ e.persistTrack == null || e.persistTrack === e.updates.track
147
+ ? e
148
+ : { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
149
+ );
150
+ const persisted = onMoveElements
151
+ ? onMoveElements(persistEdits, coalesceKey, operation)
152
+ : Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
153
+ return Promise.resolve(persisted).then(
154
+ () => true,
155
+ (error) => {
156
+ for (const p of prev) {
157
+ if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
158
+ updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
159
+ }
160
+ }
161
+ console.error("[Timeline] Failed to persist clip edits", error);
162
+ return false;
163
+ },
164
+ );
165
+ }
166
+
167
+ /**
168
+ * A fractional track value for a NEW lane inserted at boundary `insertRow` in
169
+ * `trackOrder` (0 = above the top, `length` = below the bottom). normalizeToZones
170
+ * then compacts it to a distinct integer lane between its neighbours, and the
171
+ * clips at/below the insert shift down by one — the sanctioned index-renumber.
172
+ */
173
+ /** Same-source-file predicate: authored track numbers only compare within ONE
174
+ * file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
175
+ * file, not the host timeline's). `undefined` means the active composition. */
176
+ const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
177
+ (a.sourceFile ?? null) === (b.sourceFile ?? null);
178
+
179
+ /**
180
+ * Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
181
+ * `dragged`. Occupants are consulted ONLY from the dragged clip's own source
182
+ * file — an occupant from a different file (e.g. an expanded sub-comp child, or
183
+ * a host clip next to expanded rows) carries authored values in a different
184
+ * coordinate space, and borrowing them would write a foreign file's numbering.
185
+ *
186
+ * Lane semantics after normalizeToZones: each distinct authored track owns one
187
+ * base lane, and time-overlapping same-track clips spill onto adjacent display
188
+ * sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
189
+ * trackOrder lists it): its occupants share the base lane's authored track by
190
+ * construction, so the same-file occupant lookup returns that authored track and
191
+ * the drop persists as a same-track join. The clip may then DISPLAY on a
192
+ * different sub-lane than it was dropped on — the spill re-packs
193
+ * deterministically by stable id, first-fit — but the persisted track is
194
+ * correct.
195
+ *
196
+ * Fallbacks when the lane has no same-file occupant (e.g. an expanded child
197
+ * dropped on a lane holding only other files' clips — the display-lane integer
198
+ * must NOT be persisted into a sparse file):
199
+ * 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
200
+ * preserving "one lane up = one authored track up" in the clip's own file.
201
+ * 2. No same-file peers at all → the lane value itself (single-clip files:
202
+ * display and authored spaces coincide for want of any other anchor).
203
+ * Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
204
+ * never here.
205
+ */
206
+ function authoredTrackForLane(
207
+ lane: number,
208
+ elements: TimelineElement[],
209
+ dragged: TimelineElement,
210
+ ): number {
211
+ const dragKey = keyOf(dragged);
212
+ const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
213
+ const occupant = peers.find((e) => e.track === lane);
214
+ if (occupant) return occupant.authoredTrack ?? occupant.track;
215
+ let nearest: TimelineElement | null = null;
216
+ for (const p of peers) {
217
+ if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
218
+ }
219
+ if (!nearest) return lane;
220
+ return (nearest.authoredTrack ?? nearest.track) + (lane - nearest.track);
221
+ }
222
+
223
+ function insertTrackValue(trackOrder: number[], insertRow: number): number {
224
+ if (trackOrder.length === 0) return 0;
225
+ if (insertRow <= 0) return trackOrder[0] - 0.5;
226
+ if (insertRow >= trackOrder.length) return trackOrder[trackOrder.length - 1] + 0.5;
227
+ return (trackOrder[insertRow - 1] + trackOrder[insertRow]) / 2;
228
+ }
229
+
230
+ /**
231
+ * Build the time-shift resolver for a multi-selection drag: every member of the
232
+ * selection moves by the dragged clip's delta (clamped ≥ 0); non-members are
233
+ * untouched. Returns null when this is not a multi-selection drag. A locked /
234
+ * implicit member is dropped from the moving set (a marquee can sweep one in).
235
+ */
236
+ function resolveMultiSelection(
237
+ drag: DraggedClipState,
238
+ deps: DragCommitDeps,
239
+ ): {
240
+ keys: ReadonlySet<string>;
241
+ movedStart: (e: TimelineElement) => number;
242
+ } | null {
243
+ const { elements, selectedKeys } = deps;
244
+ const dragKey = keyOf(drag.element);
245
+ if (!selectedKeys || selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return null;
246
+ const keys = new Set(
247
+ [...selectedKeys].filter((k) => {
248
+ const el = elements.find((e) => keyOf(e) === k);
249
+ return el ? canMoveElement(el) : false;
250
+ }),
251
+ );
252
+ const delta = drag.previewStart - drag.element.start;
253
+ const movedStart = (e: TimelineElement): number =>
254
+ keyOf(e) === dragKey ? drag.previewStart : Math.max(0, round3(e.start + delta));
255
+ return { keys, movedStart };
256
+ }
257
+
258
+ /**
259
+ * Commit a finished clip drag.
260
+ *
261
+ * The lane model is CapCut-stable: a clip's display lane is its track, and editing
262
+ * ONE clip must never re-lane or rewrite OTHER clips. Three outcomes:
263
+ *
264
+ * - **Pure time-move** (dragged clip keeps its lane, no insert): persist just the
265
+ * dragged clip's start (multi-selection shifts every selected clip in time).
266
+ * - **Lane change / collision relocation** (the dragged clip's OWN lane changes,
267
+ * no new track): persist ONLY the dragged clip's start + lane. No other clip is
268
+ * touched. z is synced only when the gesture is a DELIBERATE vertical move
269
+ * (the pointer aimed at another lane) — a horizontal drag merely bumped to a
270
+ * free lane never restacks.
271
+ * - **Track insert** (a new lane at a gap boundary): the dragged clip lands on
272
+ * the new lane and the clips at/below the insert are renumbered by +1 (the ONLY
273
+ * permitted multi-clip write) via a whole-set re-normalize; persisted atomically.
274
+ */
275
+ // fallow-ignore-next-line complexity
276
+ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
277
+ const { elements, updateElement, onMoveElement } = deps;
278
+ const dragKey = keyOf(drag.element);
279
+ const isInsert = drag.insertRow != null;
280
+ const laneChanged = drag.previewTrack !== drag.element.track;
281
+ // Deliberate VERTICAL gesture: the pointer aimed at a different lane, or at a
282
+ // gap boundary (insert). A plain HORIZONTAL drag whose target span is occupied
283
+ // gets the DRAGGED clip bumped to a free lane (previewTrack differs) while the
284
+ // pointer never left its lane (desiredTrack === element.track) — that is NOT a
285
+ // vertical move: it must neither rewrite other clips nor touch z.
286
+ const aimTrack = drag.desiredTrack ?? drag.previewTrack;
287
+ const isVertical = isInsert || aimTrack !== drag.element.track;
288
+ const multi = resolveMultiSelection(drag, deps);
289
+
290
+ // ── Pure time-move (dragged clip keeps its lane, no insert) ─────────────────
291
+ if (!isInsert && !laneChanged) {
292
+ const delta = drag.previewStart - drag.element.start;
293
+ if (delta === 0) return;
294
+ if (multi) {
295
+ const edits: TimelineMoveEdit[] = elements
296
+ .filter((e) => multi.keys.has(keyOf(e)))
297
+ .map((e) => ({
298
+ element: e,
299
+ updates: { start: multi.movedStart(e), track: e.track },
300
+ }))
301
+ .filter((e) => e.updates.start !== e.element.start);
302
+ void persistMoveEdits(edits, deps);
303
+ return;
304
+ }
305
+ const updates = { start: drag.previewStart, track: drag.element.track };
306
+ const prev = { start: drag.element.start, track: drag.element.track };
307
+ const revision = beginTimelineOptimisticGesture(updateElement, [dragKey]);
308
+ updateElement(dragKey, updates);
309
+ Promise.resolve(onMoveElement?.(drag.element, updates)).catch((error) => {
310
+ if (isLatestTimelineOptimisticGesture(updateElement, revision, dragKey)) {
311
+ updateElement(dragKey, prev);
312
+ }
313
+ console.error("[Timeline] Failed to persist clip edit", error);
314
+ });
315
+ return;
316
+ }
317
+
318
+ // ── Track insert: renumber the at/below clips by +1 (the one multi-clip write) ─
319
+ if (isInsert) {
320
+ commitTrackInsert(drag, deps, multi);
321
+ return;
322
+ }
323
+
324
+ // ── Lane change / collision relocation: persist ONLY the dragged clip ────────
325
+ // CapCut invariant — one edit never re-lanes another clip. The dragged clip
326
+ // takes its new lane (previewTrack); the rest of any selection shifts in time
327
+ // only. Nothing else is written.
328
+ const dragEdit: TimelineMoveEdit = {
329
+ element: drag.element,
330
+ updates: { start: drag.previewStart, track: drag.previewTrack },
331
+ persistTrack: authoredTrackForLane(drag.previewTrack, elements, drag.element),
332
+ };
333
+ const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
334
+
335
+ const edits: TimelineMoveEdit[] = [dragEdit];
336
+ if (multi) {
337
+ for (const e of elements) {
338
+ if (keyOf(e) === dragKey || !multi.keys.has(keyOf(e))) continue;
339
+ const start = multi.movedStart(e);
340
+ if (start !== e.start) edits.push({ element: e, updates: { start, track: e.track } });
341
+ }
342
+ }
343
+ // The drop-intent set for the z-sync: the dragged clip at its new lane, others
344
+ // as-is. Reasoning on this (not a re-normalize) keeps the sync seeing the user's
345
+ // move; computeStackingPatches only compares lanes relatively.
346
+ const candidate = elements.map((e) => {
347
+ if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: drag.previewTrack };
348
+ // Selection members shift in time with the drag — the z-sync must reason on
349
+ // their POST-move overlap sets, same as the insert branch's candidate.
350
+ if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
351
+ return e;
352
+ });
353
+ const multiKeys = multi ? multi.keys : null;
354
+ void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder").then((moved) => {
355
+ if (moved && isVertical) {
356
+ syncStackingForEdit(
357
+ candidate,
358
+ dragKey,
359
+ drag.element.track,
360
+ drag.previewTrack,
361
+ multiKeys,
362
+ deps,
363
+ coalesceKey,
364
+ );
365
+ }
366
+ });
367
+ }
368
+
369
+ /**
370
+ * Insert a new track at the drop's gap boundary. The dragged clip lands on the
371
+ * fractional insert lane; normalizeToZones then compacts every lane to a contiguous
372
+ * integer, which shifts the clips at/below the insert down by one. That +1
373
+ * renumber is the ONLY sanctioned multi-clip write; it is index-only (never z).
374
+ * The whole affected set is persisted atomically (single undo), and the deliberate
375
+ * vertical move syncs the dragged clip's stacking afterwards.
376
+ */
377
+ // fallow-ignore-next-line complexity
378
+ function commitTrackInsert(
379
+ drag: DraggedClipState,
380
+ deps: DragCommitDeps,
381
+ multi: {
382
+ keys: ReadonlySet<string>;
383
+ movedStart: (e: TimelineElement) => number;
384
+ } | null,
385
+ ): void {
386
+ const { elements, trackOrder } = deps;
387
+ const dragKey = keyOf(drag.element);
388
+ const targetTrack = insertTrackValue(trackOrder, drag.insertRow!);
389
+ // Drop-intent set: dragged clip at the fractional insert lane (so it sorts
390
+ // between its neighbours), selection members time-shifted, others as-is.
391
+ const candidate = elements.map((e) => {
392
+ if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: targetTrack };
393
+ if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
394
+ return e;
395
+ });
396
+ // normalizeToZones compacts the fractional lane to a contiguous integer, which
397
+ // shifts the at/below clips down by one — the sanctioned +1 index renumber.
398
+ const normalized = normalizeToZones(candidate);
399
+ const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
400
+ // The renumber is only correct as a WHOLE-SET write: skipping an unwritable
401
+ // clip whose lane shifts leaves its track colliding with a renumbered
402
+ // neighbour, and the next normalize merges the two lanes. If any shifted clip
403
+ // can't be written, refuse the insert instead of persisting a broken layout.
404
+ for (const norm of normalized) {
405
+ const src = bySrc.get(keyOf(norm));
406
+ if (src && !canMoveElement(src) && norm.track !== src.track) {
407
+ console.warn(
408
+ `[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
409
+ );
410
+ return;
411
+ }
412
+ }
413
+ const edits: TimelineMoveEdit[] = [];
414
+ for (const norm of normalized) {
415
+ const src = bySrc.get(keyOf(norm));
416
+ if (!src) continue;
417
+ // Capabilities gate (unchanged-lane clips only reach here now): never write
418
+ // a locked/implicit clip.
419
+ if (!canMoveElement(src)) continue;
420
+ const start =
421
+ keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm))
422
+ ? (multi?.movedStart(src) ?? drag.previewStart)
423
+ : src.start;
424
+ edits.push({ element: src, updates: { start, track: norm.track } });
425
+ }
426
+
427
+ const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
428
+ void persistMoveEdits(edits, deps, coalesceKey, "track-insert").then((moved) => {
429
+ // Skip the z-sync when the insert produced NO move edits (e.g. every clip in
430
+ // the set is locked/implicit and gets filtered out). persistMoveEdits resolves
431
+ // `true` for an empty batch so the caller's serialization proceeds, but firing
432
+ // the z-sync here would record an orphaned z-only history entry for a move that
433
+ // never persisted.
434
+ if (moved && edits.length > 0) {
435
+ // Reason the z-sync on the drop-intent `candidate` (dragged clip at its
436
+ // fractional insert lane) — NOT the re-normalized lanes — so the sync sees
437
+ // the user's move. The guard lane is the aimed insert row (a boundary in
438
+ // display-lane space, comparable to the clip's contiguous current lane).
439
+ syncStackingForEdit(
440
+ candidate,
441
+ dragKey,
442
+ drag.element.track,
443
+ drag.insertRow!,
444
+ multi ? multi.keys : null,
445
+ deps,
446
+ coalesceKey,
447
+ );
448
+ }
449
+ });
450
+ }
451
+
452
+ /**
453
+ * Compute + apply z-index patches for the edited clip(s) after a DELIBERATE
454
+ * vertical lane change. Projects the drop-intent element set (`candidate`: the
455
+ * dragged clip at its new / fractional-insert lane, others at their current tracks)
456
+ * onto StackingElement using the caller-supplied live z-index reader, then
457
+ * delegates the minimal-z resolution to computeStackingPatches — a clip on the
458
+ * upper lane paints above every clip it time-overlaps. No-op unless both z-sync
459
+ * deps are present, and never when the gesture aimed at the clip's OWN current
460
+ * lane (`aimedLane === currentLane` — not a relocation).
461
+ */
462
+ function syncStackingForEdit(
463
+ candidate: TimelineElement[],
464
+ dragKey: string,
465
+ currentLane: number,
466
+ aimedLane: number,
467
+ multiKeys: ReadonlySet<string> | null,
468
+ deps: DragCommitDeps,
469
+ coalesceKey?: string,
470
+ ): void {
471
+ const { readZIndex, onStackingPatches } = deps;
472
+ if (!readZIndex || !onStackingPatches) return;
473
+
474
+ // Aiming at the clip's OWN current display lane is not a relocation — never
475
+ // touch z (guards the pure-time-move invariant even if a spurious topology call
476
+ // slips through). Every real lane-realization drop aims at a DIFFERENT lane.
477
+ if (aimedLane === currentLane) return;
478
+
479
+ // `candidate` is in discovery order, so its array index IS the DOM document
480
+ // position. Equal-z clips paint by DOM order, so the sync needs it to decide
481
+ // "is A above B" (see StackingElement.domIndex).
482
+ const stackingEls = candidate.map((el, domIndex) => ({
483
+ key: keyOf(el),
484
+ start: el.start,
485
+ duration: el.duration,
486
+ track: el.track,
487
+ zIndex: readZIndex(el),
488
+ isAudio: classifyZone(el) === "audio",
489
+ domIndex,
490
+ stackingContextId: el.stackingContextId ?? null,
491
+ }));
492
+
493
+ const editedKeys = [dragKey];
494
+ if (multiKeys) for (const k of multiKeys) if (k !== dragKey) editedKeys.push(k);
495
+
496
+ const patches = computeStackingPatches(stackingEls, editedKeys);
497
+ if (patches.length > 0) onStackingPatches(patches, coalesceKey);
498
+ }
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { computeDragPreview, type DragPreviewContext } from "./timelineClipDragPreview";
4
+ import type { DraggedClipState } from "./timelineClipDragTypes";
5
+ import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
6
+
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+ // Regression bed for the live-reproduced BUG 1: a PLAIN HORIZONTAL drag of a clip
9
+ // on its own top lane armed a phantom new-track insert (the old 0.32 insert band
10
+ // reached deep into the clip body). That insert flipped the commit into the
11
+ // lane-change branch, which nudged the clip's z-index and re-sorted it off its
12
+ // lane. The invariant: a horizontal drag over a clip BODY → insertRow === null,
13
+ // previewTrack unchanged (a pure time move — zero topology change, zero z sync).
14
+ //
15
+ // Elements mirror the user's index.html shapes: a high-z "v-moodboard" alone on
16
+ // the top display lane, over several lower-lane video clips it overlaps in time,
17
+ // plus a caption. Tracks here are already the normalized DISPLAY lanes (the store
18
+ // runs normalizeToZones on discovery), matching what the drag hook passes in.
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+
21
+ const PPS = 40;
22
+
23
+ function clip(
24
+ id: string,
25
+ track: number,
26
+ start: number,
27
+ duration: number,
28
+ zIndex: number,
29
+ tag = "video",
30
+ ): TimelineElement {
31
+ return { id, key: id, tag, start, duration, track, zIndex, domId: id };
32
+ }
33
+
34
+ // v-moodboard: own top lane (0). Lower lane (1) carries overlapping video clips;
35
+ // captions sit on lane 2. trackOrder = [0, 1, 2].
36
+ const moodboard = clip("v-moodboard", 0, 19, 5.5, 37);
37
+ const fixtureElements: TimelineElement[] = [
38
+ moodboard,
39
+ clip("v-dashboard", 1, 19, 4, 16),
40
+ clip("v-globe", 1, 23, 1.5, 17),
41
+ clip("cap", 2, 20.82, 1.78, 0, "text"),
42
+ ];
43
+
44
+ // A scroll container whose content-space y equals clientY (rect top 0, no scroll).
45
+ function fakeScroll(): HTMLDivElement {
46
+ return {
47
+ getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }),
48
+ scrollLeft: 0,
49
+ scrollTop: 0,
50
+ scrollWidth: 100000,
51
+ } as unknown as HTMLDivElement;
52
+ }
53
+
54
+ function ctx(): DragPreviewContext {
55
+ return {
56
+ scroll: fakeScroll(),
57
+ pps: PPS,
58
+ duration: 44.5,
59
+ trackOrder: [0, 1, 2],
60
+ elements: fixtureElements,
61
+ selectedKeys: new Set<string>(),
62
+ buildSnapTargets: () => [],
63
+ audioTracks: new Set<number>(),
64
+ };
65
+ }
66
+
67
+ // content-space y for a fractional row index (inverse of getTimelineRowFromY).
68
+ const yForRow = (rowFloat: number) => RULER_H + TRACKS_TOP_PAD + rowFloat * TRACK_H;
69
+
70
+ // A drag grabbing `element` at vertical position `grabRowFloat` within its lane.
71
+ function horizontalDrag(
72
+ element: TimelineElement,
73
+ grabRowFloat: number,
74
+ deltaSeconds: number,
75
+ ): { drag: DraggedClipState; clientX: number; clientY: number } {
76
+ const originClientX = 800;
77
+ const originClientY = yForRow(grabRowFloat);
78
+ const drag: DraggedClipState = {
79
+ element,
80
+ originClientX,
81
+ originClientY,
82
+ originScrollLeft: 0,
83
+ originScrollTop: 0,
84
+ pointerClientX: originClientX,
85
+ pointerClientY: originClientY,
86
+ pointerOffsetX: 0,
87
+ pointerOffsetY: 0,
88
+ previewStart: element.start,
89
+ previewTrack: element.track,
90
+ insertRow: null,
91
+ snapTime: null,
92
+ snapType: null,
93
+ started: true,
94
+ };
95
+ // Horizontal: clientY stays at the grab point; only x advances by the delta.
96
+ return { drag, clientX: originClientX + deltaSeconds * PPS, clientY: originClientY };
97
+ }
98
+
99
+ describe("computeDragPreview — plain horizontal drag never arms a phantom insert (BUG 1)", () => {
100
+ it("dragging v-moodboard +2s while grabbing its clip body keeps it a pure time move", () => {
101
+ const { drag, clientX, clientY } = horizontalDrag(moodboard, 0.5, 2);
102
+ const next = computeDragPreview(drag, clientX, clientY, ctx());
103
+ expect(next.insertRow).toBeNull(); // no phantom new-track insert
104
+ expect(next.previewTrack).toBe(0); // stays on its own lane
105
+ expect(next.desiredTrack).toBe(0); // pointer never left lane 0 → not a vertical aim
106
+ expect(next.previewStart).toBeCloseTo(21, 5); // +2s moved
107
+ });
108
+
109
+ it("grabbing ANYWHERE across the clip body (not just dead-center) stays a pure time move", () => {
110
+ // Sweep the whole clip body of lane 0; a horizontal drag must never insert.
111
+ for (let grab = 0.1; grab <= 0.9 + 1e-9; grab += 0.1) {
112
+ const { drag, clientX, clientY } = horizontalDrag(moodboard, grab, 2);
113
+ const next = computeDragPreview(drag, clientX, clientY, ctx());
114
+ expect(next.insertRow).toBeNull();
115
+ expect(next.previewTrack).toBe(0);
116
+ }
117
+ });
118
+
119
+ it("aiming the gutter ABOVE the top lane arms a top insert (UX rule 2)", () => {
120
+ // Drag v-moodboard up into the top breathing pad → insert a new top track.
121
+ const originClientX = 800;
122
+ const originClientY = yForRow(0.5);
123
+ const drag: DraggedClipState = {
124
+ element: moodboard,
125
+ originClientX,
126
+ originClientY,
127
+ originScrollLeft: 0,
128
+ originScrollTop: 0,
129
+ pointerClientX: originClientX,
130
+ pointerClientY: originClientY,
131
+ pointerOffsetX: 0,
132
+ pointerOffsetY: 0,
133
+ previewStart: moodboard.start,
134
+ previewTrack: moodboard.track,
135
+ insertRow: null,
136
+ snapTime: null,
137
+ snapType: null,
138
+ started: true,
139
+ };
140
+ // Pointer well above the first lane (into the top pad → rowFloat < 0).
141
+ const next = computeDragPreview(drag, originClientX, yForRow(-0.6), ctx());
142
+ expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
143
+ });
144
+ });