@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,1026 @@
1
+ import { describe, expect, it, vi, type Mock } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import type { DraggedClipState } from "./useTimelineClipDrag";
4
+ import {
5
+ commitDraggedClipMove,
6
+ type DragCommitDeps,
7
+ type TimelineMoveEdit,
8
+ } from "./timelineClipDragCommit";
9
+ import {
10
+ buildEditHistoryEntry,
11
+ createEmptyEditHistory,
12
+ pushEditHistoryEntry,
13
+ } from "../../utils/editHistory";
14
+ import { normalizeToZones } from "./timelineZones";
15
+ import type { StackingPatch } from "./timelineStackingSync";
16
+
17
+ function el(
18
+ id: string,
19
+ track: number,
20
+ start: number,
21
+ duration: number,
22
+ tag = "video",
23
+ ): TimelineElement {
24
+ // domId gives the row a patchable target so getTimelineEditCapabilities().canMove
25
+ // is true (the capabilities gate in commitDraggedClipMove filters on it).
26
+ return { id, key: id, tag, start, duration, track, domId: id };
27
+ }
28
+
29
+ /** Flush the microtask chain: the z-sync now fires only after the move persist
30
+ * promise resolves (serialized), so tests asserting on it must await. */
31
+ async function flushMicrotasks(): Promise<void> {
32
+ await Promise.resolve();
33
+ await Promise.resolve();
34
+ await Promise.resolve();
35
+ }
36
+
37
+ function drag(
38
+ element: TimelineElement,
39
+ opts: {
40
+ previewStart: number;
41
+ previewTrack: number;
42
+ desiredTrack?: number;
43
+ insertRow?: number | null;
44
+ },
45
+ ): DraggedClipState {
46
+ return {
47
+ element,
48
+ originClientX: 0,
49
+ originClientY: 0,
50
+ originScrollLeft: 0,
51
+ originScrollTop: 0,
52
+ pointerClientX: 0,
53
+ pointerClientY: 0,
54
+ pointerOffsetX: 0,
55
+ pointerOffsetY: 0,
56
+ previewStart: opts.previewStart,
57
+ previewTrack: opts.previewTrack,
58
+ // Defaults to previewTrack (a lane change) when a test doesn't distinguish a
59
+ // horizontal collision bump — the commit's `desiredTrack ?? previewTrack`.
60
+ desiredTrack: opts.desiredTrack,
61
+ insertRow: opts.insertRow ?? null,
62
+ snapTime: null,
63
+ snapType: null,
64
+ started: true,
65
+ };
66
+ }
67
+
68
+ function editMap(edits: TimelineMoveEdit[]): Record<string, { start: number; track: number }> {
69
+ const out: Record<string, { start: number; track: number }> = {};
70
+ for (const e of edits)
71
+ out[e.element.key ?? e.element.id] = {
72
+ start: e.updates.start,
73
+ track: e.updates.track,
74
+ };
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * Run a drag commit with fresh spies for the three persist callbacks, returning
80
+ * them so a test can assert on whichever path it exercised. Any other dep
81
+ * (trackOrder, selectedKeys, readZIndex, onStackingPatches, …) is passed through.
82
+ */
83
+ function runClipMove(
84
+ dragState: DraggedClipState,
85
+ deps: Omit<DragCommitDeps, "onMoveElement" | "onMoveElements" | "updateElement">,
86
+ ): { updateElement: Mock; onMoveElement: Mock; onMoveElements: Mock } {
87
+ const updateElement = vi.fn();
88
+ const onMoveElement = vi.fn();
89
+ const onMoveElements = vi.fn();
90
+ commitDraggedClipMove(dragState, {
91
+ ...deps,
92
+ updateElement,
93
+ onMoveElement,
94
+ onMoveElements,
95
+ });
96
+ return { updateElement, onMoveElement, onMoveElements };
97
+ }
98
+
99
+ /** A drag onto another lane where the two clips do NOT time-overlap must issue no
100
+ * stacking patch (the dragged clip is elements[0]). Awaits the serialized z-sync. */
101
+ async function expectNoStackingPatchOnNonOverlapLaneChange(
102
+ elements: TimelineElement[],
103
+ ): Promise<void> {
104
+ const onStackingPatches = vi.fn();
105
+ runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), {
106
+ elements,
107
+ trackOrder: [0, 1],
108
+ readZIndex: () => 0,
109
+ onStackingPatches,
110
+ });
111
+ await flushMicrotasks();
112
+ expect(onStackingPatches).not.toHaveBeenCalled();
113
+ }
114
+
115
+ type MoveMap = Record<string, { start: number; track: number }>;
116
+
117
+ /** Assert a lane change persisted atomically (single onMoveElements, no single
118
+ * onMoveElement) and return the resulting id → {start, track} edit map. */
119
+ function expectAtomicMoveMap(spies: { onMoveElement: Mock; onMoveElements: Mock }): MoveMap {
120
+ expect(spies.onMoveElement).not.toHaveBeenCalled();
121
+ expect(spies.onMoveElements).toHaveBeenCalledTimes(1);
122
+ return editMap(spies.onMoveElements.mock.calls[0][0]);
123
+ }
124
+
125
+ // Two time-overlapping clips carrying authored z (a below at z=1, b on top at
126
+ // z=5) — the bed for the z-sync / lane-change tests.
127
+ const overlapping = (): TimelineElement[] => [
128
+ {
129
+ id: "a",
130
+ key: "a",
131
+ tag: "video",
132
+ start: 0,
133
+ duration: 10,
134
+ track: 1,
135
+ zIndex: 1,
136
+ domId: "a",
137
+ },
138
+ {
139
+ id: "b",
140
+ key: "b",
141
+ tag: "video",
142
+ start: 0,
143
+ duration: 10,
144
+ track: 0,
145
+ zIndex: 5,
146
+ domId: "b",
147
+ },
148
+ ];
149
+ const zOf = (e: TimelineElement) => ({ a: 1, b: 5 })[e.key ?? e.id] ?? 0;
150
+
151
+ // Commit an "insert a above b" lane change: the drop lifts a's z, so both the
152
+ // move persist and the z-sync fire. Tests drive onMoveElements differently
153
+ // (immediate / pending / rejecting), so the persist deps are supplied per call.
154
+ function commitInsertAbove(
155
+ elements: TimelineElement[],
156
+ deps: Partial<DragCommitDeps> & Pick<DragCommitDeps, "onMoveElements" | "onStackingPatches">,
157
+ ): void {
158
+ commitDraggedClipMove(drag(elements[0], { previewStart: 0, previewTrack: 1, insertRow: 0 }), {
159
+ elements,
160
+ trackOrder: [0, 1],
161
+ updateElement: vi.fn(),
162
+ onMoveElement: vi.fn(),
163
+ readZIndex: zOf,
164
+ ...deps,
165
+ });
166
+ }
167
+
168
+ // The edited clip `a` is lifted above b(5) → 6, issued as one stacking patch.
169
+ function expectZLiftedToSix(onStackingPatches: Mock): void {
170
+ expect(onStackingPatches).toHaveBeenCalledTimes(1);
171
+ expect(onStackingPatches.mock.calls[0][0]).toEqual([{ key: "a", zIndex: 6 }]);
172
+ }
173
+
174
+ // A marquee (a+b selected) time-move of the dragged clip; returns the persist
175
+ // spies plus the resulting id → {start, track} edit map.
176
+ function runMarqueeMove(
177
+ dragged: TimelineElement,
178
+ previewStart: number,
179
+ elements: TimelineElement[],
180
+ ): {
181
+ updateElement: Mock;
182
+ onMoveElement: Mock;
183
+ onMoveElements: Mock;
184
+ map: MoveMap;
185
+ } {
186
+ const spies = runClipMove(drag(dragged, { previewStart, previewTrack: 0 }), {
187
+ elements,
188
+ trackOrder: [0, 1],
189
+ selectedKeys: new Set(["a", "b"]),
190
+ });
191
+ return { ...spies, map: editMap(spies.onMoveElements.mock.calls[0][0]) };
192
+ }
193
+
194
+ describe("commitDraggedClipMove", () => {
195
+ it("pure time-move (same lane) persists just the dragged clip (single, SDK-aware)", () => {
196
+ const elements = [el("v1", 1, 0, 5)];
197
+ // previewTrack === element.track → no topology change → single move.
198
+ const { onMoveElement, onMoveElements } = runClipMove(
199
+ drag(elements[0], { previewStart: 6, previewTrack: 1 }),
200
+ { elements, trackOrder: [1] },
201
+ );
202
+ expect(onMoveElements).not.toHaveBeenCalled();
203
+ expect(onMoveElement).toHaveBeenCalledTimes(1);
204
+ expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 6, track: 1 });
205
+ });
206
+
207
+ it("a plain lane change persists ONLY the dragged clip (CapCut: never re-lanes others)", () => {
208
+ // Move 'a' from lane 0 down onto lane 1 (b's lane) at a non-overlapping time.
209
+ // The CapCut rule: editing one clip must never rewrite another. Only 'a' is
210
+ // persisted — with its new lane (previewTrack 1) — and 'b' is left untouched.
211
+ const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3)];
212
+ const { onMoveElements } = runClipMove(
213
+ drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }),
214
+ { elements, trackOrder: [0, 1] },
215
+ );
216
+ const map = editMap(onMoveElements.mock.calls[0][0]);
217
+ expect(onMoveElements.mock.calls[0][2]).toBe("lane-reorder");
218
+ expect(map.a).toEqual({ start: 20, track: 1 });
219
+ expect(map.b).toBeUndefined(); // the other clip is NOT rewritten
220
+ });
221
+
222
+ it("persists a lane change in AUTHORED track space when the file is sparse", () => {
223
+ // Discovery normalized authored tracks {1, 2} to display lanes {0, 1}
224
+ // (authoredTrack records the file value). Moving 'a' onto b's lane must
225
+ // write b's AUTHORED track (2) — writing the display lane (1) would target
226
+ // a's own authored row and silently no-op in the file.
227
+ const elements = [
228
+ { ...el("a", 0, 0, 3), authoredTrack: 1 },
229
+ { ...el("b", 1, 10, 3), authoredTrack: 2 },
230
+ ];
231
+ const { updateElement, onMoveElements } = runClipMove(
232
+ drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }),
233
+ { elements, trackOrder: [0, 1] },
234
+ );
235
+ // Store stays in display-lane space, but authoredTrack is refreshed to the
236
+ // value just written to the file so a SECOND drag before any reload resolves
237
+ // authored tracks from current data, not the stale pre-edit value.
238
+ expect(updateElement).toHaveBeenCalledWith("a", { start: 20, track: 1, authoredTrack: 2 });
239
+ // ...while the persist is translated to the target lane's authored track.
240
+ const map = editMap(onMoveElements.mock.calls[0][0]);
241
+ expect(map.a).toEqual({ start: 20, track: 2 });
242
+ });
243
+
244
+ it("resolves the authored track from occupants of the dragged clip's OWN source file", () => {
245
+ // Expanded sub-comp children live on synthetic display lanes next to host
246
+ // clips. Their authoredTrack is in THEIR file's coordinate space, so a lane
247
+ // occupied by a clip from a DIFFERENT file must never lend its authored
248
+ // value. Here lane 1 holds both a host-file clip (authored 12) and a
249
+ // same-file sibling (authored 7): the sibling answers.
250
+ const child3 = { ...el("c3", 0, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
251
+ const child7 = { ...el("c7", 1, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
252
+ const hostClip = { ...el("h", 1, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
253
+ const elements = [child3, child7, hostClip];
254
+ const { onMoveElements } = runClipMove(
255
+ drag(child3, { previewStart: 0, previewTrack: 1, desiredTrack: 1 }),
256
+ { elements, trackOrder: [0, 1] },
257
+ );
258
+ const map = editMap(onMoveElements.mock.calls[0][0]);
259
+ expect(map.c3).toEqual({ start: 0, track: 7 });
260
+ });
261
+
262
+ it("never persists the display-lane integer when the target lane has no same-file occupant", () => {
263
+ // Reviewer scenario: an expanded child of a SPARSE file (authored tracks 3
264
+ // and 7 → display lanes 4 and 5) dragged onto display lane 6, which holds
265
+ // only another file's clip. Persisting 6 (the display integer) or 12 (the
266
+ // foreign authored value) would corrupt the sparse file; the fallback
267
+ // offsets from the NEAREST same-file lane instead: authored 7 at lane 5,
268
+ // one lane further down → 8.
269
+ const child3 = { ...el("c3", 4, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
270
+ const child7 = { ...el("c7", 5, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
271
+ const foreign = { ...el("f", 6, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
272
+ const elements = [child3, child7, foreign];
273
+ const { onMoveElements } = runClipMove(
274
+ drag(child3, { previewStart: 0, previewTrack: 6, desiredTrack: 6 }),
275
+ { elements, trackOrder: [4, 5, 6] },
276
+ );
277
+ const map = editMap(onMoveElements.mock.calls[0][0]);
278
+ expect(map.c3.track).not.toBe(6); // not the display-lane integer
279
+ expect(map.c3.track).not.toBe(12); // not the foreign file's authored value
280
+ expect(map.c3).toEqual({ start: 0, track: 8 });
281
+ });
282
+
283
+ it("dropping onto an overlap spill sub-lane persists the base lane's shared authored track", () => {
284
+ // Authored track 2 holds two time-overlapping clips, which packTrackLanes
285
+ // spills onto display sub-lanes 1 and 2 (both authoredTrack 2). Dropping
286
+ // 'a' onto the spill sub-lane (2) is a legitimate same-track join: the
287
+ // persisted value is the shared authored track (2), even though the clip
288
+ // may re-pack onto a different sub-lane on the next normalize.
289
+ const elements = normalizeToZones([
290
+ { ...el("a", 0, 30, 3), authoredTrack: 1 },
291
+ { ...el("b1", 2, 0, 5), authoredTrack: 2 },
292
+ { ...el("b2", 2, 3, 5), authoredTrack: 2 }, // overlaps b1 → spills
293
+ ]);
294
+ expect(elements.map((e) => e.track)).toEqual([0, 1, 2]); // spill happened
295
+ const spillLane = elements.find((e) => e.id === "b2")!.track;
296
+ const { onMoveElements } = runClipMove(
297
+ drag(elements[0], { previewStart: 30, previewTrack: spillLane, desiredTrack: spillLane }),
298
+ { elements, trackOrder: [0, 1, 2] },
299
+ );
300
+ const map = editMap(onMoveElements.mock.calls[0][0]);
301
+ expect(map.a).toEqual({ start: 30, track: 2 });
302
+ });
303
+
304
+ it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => {
305
+ const elements = [el("a", 0, 2, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
306
+ // Drag 'a' +5s on its own lane while {a, b} are marquee-selected.
307
+ const { onMoveElement, onMoveElements } = runClipMove(
308
+ drag(elements[0], { previewStart: 7, previewTrack: 0 }),
309
+ { elements, trackOrder: [0, 1, 2], selectedKeys: new Set(["a", "b"]) },
310
+ );
311
+ const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
312
+ expect(map.a).toEqual({ start: 7, track: 0 });
313
+ expect(map.b).toEqual({ start: 15, track: 1 }); // same +5 delta, keeps its lane
314
+ expect(map.c).toBeUndefined(); // unselected clips untouched
315
+ });
316
+
317
+ it("multi-selection move clamps shifted clips at 0 and applies the store update optimistically", () => {
318
+ const elements = [el("a", 0, 6, 3), el("b", 1, 2, 3)];
319
+ // Drag 'a' −5s: b would land at −3 → clamps to 0.
320
+ const { updateElement, map } = runMarqueeMove(elements[0], 1, elements);
321
+ expect(map.a).toEqual({ start: 1, track: 0 });
322
+ expect(map.b).toEqual({ start: 0, track: 1 });
323
+ expect(updateElement).toHaveBeenCalledWith("a", { start: 1, track: 0 });
324
+ expect(updateElement).toHaveBeenCalledWith("b", { start: 0, track: 1 });
325
+ });
326
+
327
+ it("a multi-selection that does NOT include the dragged clip moves only the dragged clip", () => {
328
+ const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3)];
329
+ const { onMoveElement, onMoveElements } = runClipMove(
330
+ drag(elements[0], { previewStart: 6, previewTrack: 0 }),
331
+ { elements, trackOrder: [0, 1], selectedKeys: new Set(["b", "x"]) },
332
+ );
333
+ expect(onMoveElements).not.toHaveBeenCalled();
334
+ expect(onMoveElement).toHaveBeenCalledTimes(1);
335
+ expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 6, track: 0 });
336
+ });
337
+
338
+ it("multi-selection lane change: dragged clip changes track, selection shifts in time, others untouched", () => {
339
+ const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
340
+ // Drag 'a' +4s down onto lane 1 (non-overlapping with b) while {a, c} selected.
341
+ const { onMoveElements } = runClipMove(
342
+ drag(elements[0], { previewStart: 4, previewTrack: 1, desiredTrack: 1 }),
343
+ { elements, trackOrder: [0, 1, 2], selectedKeys: new Set(["a", "c"]) },
344
+ );
345
+ const map = editMap(onMoveElements.mock.calls[0][0]);
346
+ expect(onMoveElements.mock.calls[0][2]).toBe("lane-reorder");
347
+ expect(map.a).toEqual({ start: 4, track: 1 }); // dragged: new time + new lane
348
+ expect(map.c).toEqual({ start: 24, track: 2 }); // passenger: same +4 delta, own lane
349
+ expect(map.b).toBeUndefined(); // unselected clip is NOT rewritten
350
+ });
351
+
352
+ it("inserting a new lane places the dragged clip at the aimed row and +1-shifts the clips below", () => {
353
+ // a,b,c all start=0 dur=5 → mutually overlapping. Insert drops c on a NEW lane
354
+ // at row 1 (between a and b). The CapCut renumber: c lands at row 1, a keeps
355
+ // row 0, and b (at/below the insert) shifts down by one to row 2. Lane order
356
+ // now follows track-index (a, c, b) — the ONLY sanctioned multi-clip write,
357
+ // index-only. Contiguous 0..2, one atomic persist.
358
+ const elements = [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)];
359
+ const { onMoveElements } = runClipMove(
360
+ drag(elements[2], { previewStart: 0, previewTrack: 2, insertRow: 1 }),
361
+ { elements, trackOrder: [0, 1, 2] },
362
+ );
363
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
364
+ expect(onMoveElements.mock.calls[0][2]).toBe("track-insert");
365
+ const map = editMap(onMoveElements.mock.calls[0][0]);
366
+ // Lanes are contiguous and distinct (no two overlapping clips share a lane).
367
+ expect(new Set([map.a.track, map.b.track, map.c.track])).toEqual(new Set([0, 1, 2]));
368
+ expect(map.a.track).toBe(0); // above the insert → unchanged
369
+ expect(map.c.track).toBe(1); // dragged clip lands on the new lane
370
+ expect(map.b.track).toBe(2); // at/below the insert → +1 shift
371
+ });
372
+
373
+ describe("lane ↔ stacking sync", () => {
374
+ it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", async () => {
375
+ // a & b overlap in time. Elements carry their authored z (as real discovery
376
+ // populates TimelineElement.zIndex from the DOM), so the per-clip pack lays
377
+ // them out by z: b (z=5) tops, a (z=1) below. The user drags a UP onto the
378
+ // TOP lane (row 0, above b) via an insert — expressing "a should stack above
379
+ // b". The z-sync must lift a above b (5) → 6 so the lane move is realised.
380
+ // (Was: equal-z candidate + drop onto b's track; that relied on the old
381
+ // key-order tie-break placing a on top, which contradicted canvas paint for
382
+ // equal z — the elements now carry z and the drop intent is an insert above.)
383
+ const elements: TimelineElement[] = [
384
+ {
385
+ id: "a",
386
+ key: "a",
387
+ tag: "video",
388
+ start: 0,
389
+ duration: 10,
390
+ track: 1,
391
+ zIndex: 1,
392
+ domId: "a",
393
+ },
394
+ {
395
+ id: "b",
396
+ key: "b",
397
+ tag: "video",
398
+ start: 0,
399
+ duration: 10,
400
+ track: 0,
401
+ zIndex: 5,
402
+ domId: "b",
403
+ },
404
+ ];
405
+ const z: Record<string, number> = { a: 1, b: 5 };
406
+ const onStackingPatches = vi.fn();
407
+ // Insert a new lane at row 0 (above the top lane) with a → a lands above b.
408
+ runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 1, insertRow: 0 }), {
409
+ elements,
410
+ trackOrder: [0, 1],
411
+ readZIndex: (e) => z[e.key ?? e.id] ?? 0,
412
+ onStackingPatches,
413
+ });
414
+ // z-sync is serialized after the move persist → deferred to a microtask.
415
+ await flushMicrotasks();
416
+ // Only `a` (the edited clip) is patched, lifted above b(5) → 6.
417
+ expectZLiftedToSix(onStackingPatches);
418
+ });
419
+
420
+ it("partial z-sync deps (no readZIndex) → move persists but no stacking call", async () => {
421
+ const elements = [el("a", 1, 0, 10), el("b", 0, 0, 10)];
422
+ // onStackingPatches present but readZIndex absent → syncStackingForEdit needs
423
+ // BOTH, so it must issue zero patches even though this IS a lane change.
424
+ const onStackingPatches = vi.fn();
425
+ const { onMoveElements } = runClipMove(
426
+ drag(elements[0], { previewStart: 0, previewTrack: 0 }),
427
+ { elements, trackOrder: [0, 1], onStackingPatches },
428
+ );
429
+ await flushMicrotasks();
430
+ // The move still persists atomically; no z patch is issued, no stacking call.
431
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
432
+ expect(onStackingPatches).not.toHaveBeenCalled();
433
+ });
434
+
435
+ it("no time overlap → no stacking patch even on a lane change", async () => {
436
+ await expectNoStackingPatchOnNonOverlapLaneChange([el("a", 1, 0, 5), el("b", 0, 10, 5)]);
437
+ });
438
+
439
+ it("pure time-move (no lane change) never triggers a stacking patch", () => {
440
+ const elements = [el("a", 0, 0, 10), el("b", 0, 0, 10)];
441
+ const onStackingPatches = vi.fn();
442
+ // same track → not a topology change → z-sync branch not reached.
443
+ runClipMove(drag(elements[0], { previewStart: 3, previewTrack: 0 }), {
444
+ elements,
445
+ trackOrder: [0],
446
+ readZIndex: () => 0,
447
+ onStackingPatches,
448
+ });
449
+ expect(onStackingPatches).not.toHaveBeenCalled();
450
+ });
451
+ });
452
+
453
+ describe("drop-intent lane realization — the dragged clip DISPLAYS on the aimed lane", () => {
454
+ // CapCut-stable lanes follow the track-index, so a gap INSERT lands the dragged
455
+ // clip exactly where aimed: it takes the new lane at the insert row and the
456
+ // clips at/below shift down by one (the sanctioned renumber). z never enters
457
+ // lane assignment, so a low-z clip aimed at the top gets the top lane regardless
458
+ // of z. These are full preview→commit→normalize round trips: apply the persisted
459
+ // track edits (and any z patches), then re-normalize — the lanes come purely
460
+ // from the track-index renumber, never from z.
461
+ const vz = (
462
+ id: string,
463
+ track: number,
464
+ start: number,
465
+ duration: number,
466
+ zIndex: number,
467
+ ): TimelineElement => ({
468
+ id,
469
+ key: id,
470
+ tag: "video",
471
+ start,
472
+ duration,
473
+ track,
474
+ zIndex,
475
+ domId: id,
476
+ });
477
+
478
+ async function roundTripLane(
479
+ elements: TimelineElement[],
480
+ dragState: DraggedClipState,
481
+ trackOrder: number[],
482
+ ): Promise<Record<string, number>> {
483
+ let edits: TimelineMoveEdit[] = [];
484
+ let patches: StackingPatch[] = [];
485
+ const z = new Map(elements.map((e) => [e.key ?? e.id, (e.zIndex as number) ?? 0]));
486
+ commitDraggedClipMove(dragState, {
487
+ elements,
488
+ trackOrder,
489
+ updateElement: vi.fn(),
490
+ onMoveElement: vi.fn(),
491
+ onMoveElements: (e: TimelineMoveEdit[]) => {
492
+ edits = e;
493
+ },
494
+ readZIndex: (el) => z.get(el.key ?? el.id) ?? 0,
495
+ onStackingPatches: (p: StackingPatch[]) => {
496
+ patches = p;
497
+ },
498
+ });
499
+ await flushMicrotasks();
500
+ const te = new Map(edits.map((e) => [e.element.key ?? e.element.id, e.updates]));
501
+ const ze = new Map(patches.map((p) => [p.key, p.zIndex]));
502
+ const persisted = elements.map((e) => {
503
+ const k = e.key ?? e.id;
504
+ const t = te.get(k);
505
+ return {
506
+ ...e,
507
+ start: t ? t.start : e.start,
508
+ track: t ? t.track : e.track,
509
+ zIndex: ze.has(k) ? ze.get(k)! : e.zIndex,
510
+ };
511
+ });
512
+ const lane: Record<string, number> = {};
513
+ for (const e of normalizeToZones(persisted)) lane[e.key ?? e.id] = e.track;
514
+ return lane;
515
+ }
516
+
517
+ it("TOP-insert of a clip that OVERLAPS NOTHING lands it on the top lane (not its z-rank)", async () => {
518
+ // top(z20)/mid(z15) share the upper lanes; dragged(z2) is sequential (no time
519
+ // overlap with anything) and low-z, so a global z-sort would sink it. Aim: top.
520
+ const elements = [vz("top", 0, 0, 3, 20), vz("mid", 5, 3, 1, 15), vz("dragged", 1, 30, 3, 2)];
521
+ const lane = await roundTripLane(
522
+ elements,
523
+ drag(elements[2], { previewStart: 30, previewTrack: 1, insertRow: 0 }),
524
+ [0, 1],
525
+ );
526
+ expect(lane.dragged).toBe(0); // aimed at the very top
527
+ expect(lane.top).toBe(1);
528
+ expect(lane.mid).toBe(2);
529
+ });
530
+
531
+ it("BETWEEN-insert of a non-overlapping clip lands it between its neighbours", async () => {
532
+ const elements = [vz("a", 0, 0, 3, 9), vz("b", 1, 0, 3, 5), vz("x", 2, 20, 5, 1)];
533
+ const lane = await roundTripLane(
534
+ elements,
535
+ drag(elements[2], { previewStart: 20, previewTrack: 2, insertRow: 1 }),
536
+ [0, 1, 2],
537
+ );
538
+ expect(lane.a).toBe(0);
539
+ expect(lane.x).toBe(1); // between a and b, as aimed
540
+ expect(lane.b).toBe(2);
541
+ });
542
+
543
+ it("TOP-insert clears a NON-overlapping clip that currently tops the timeline", async () => {
544
+ // X overlaps M but NOT T (T tops the timeline, disjoint in time). Aiming X at
545
+ // the top must lift it past T even though they never overlap.
546
+ const elements = [vz("T", 0, 0, 3, 9), vz("M", 1, 10, 5, 5), vz("X", 2, 10, 5, 1)];
547
+ const lane = await roundTripLane(
548
+ elements,
549
+ drag(elements[2], { previewStart: 10, previewTrack: 2, insertRow: 0 }),
550
+ [0, 1, 2],
551
+ );
552
+ expect(lane.X).toBe(0); // aimed top, cleared the non-overlapping T
553
+ });
554
+
555
+ it("dragging X among overlapping neighbours preserves the RELATIVE order of the others (symptom 2)", async () => {
556
+ // Four mutually-overlapping clips. Baseline lanes by z: n1,n2,x,n3. Drag x to
557
+ // the top. Everything above x shifts down by one to make room (unavoidable),
558
+ // but no TWO non-dragged clips may swap — a lane change of X must not reshuffle
559
+ // its neighbours among themselves.
560
+ const elements = [
561
+ vz("n1", 0, 0, 10, 4),
562
+ vz("n2", 1, 0, 10, 3),
563
+ vz("x", 2, 0, 10, 2),
564
+ vz("n3", 3, 0, 10, 1),
565
+ ];
566
+ const baseOrder = normalizeToZones(elements)
567
+ .slice()
568
+ .sort((a, b) => a.track - b.track)
569
+ .map((e) => e.id)
570
+ .filter((id) => id !== "x");
571
+ const lane = await roundTripLane(
572
+ elements,
573
+ drag(elements[2], { previewStart: 0, previewTrack: 2, insertRow: 0 }),
574
+ [0, 1, 2, 3],
575
+ );
576
+ expect(lane.x).toBe(0); // X lands where aimed
577
+ const finalOrder = Object.keys(lane)
578
+ .sort((a, b) => lane[a] - lane[b])
579
+ .filter((id) => id !== "x");
580
+ expect(finalOrder).toEqual(baseOrder); // neighbours keep their relative order
581
+ });
582
+
583
+ it("a drop that merely SHARES a lane with a non-overlapping neighbour issues no z nudge", async () => {
584
+ // a → b's lane; they don't overlap → they share the lane, aim already met, so
585
+ // no stacking patch (byte-identical to the pre-fix no-op path).
586
+ await expectNoStackingPatchOnNonOverlapLaneChange([
587
+ vz("a", 1, 0, 5, 0),
588
+ vz("b", 0, 10, 5, 0),
589
+ ]);
590
+ });
591
+ });
592
+
593
+ describe("BUG 1 — a plain horizontal move never patches z (fixture repro)", () => {
594
+ // Mirror the user's index.html: v-moodboard alone on the top display lane (0),
595
+ // highest z, over lower-lane clips it OVERLAPS in time. A horizontal drag (the
596
+ // preview yields insertRow=null, previewTrack unchanged) must be a pure time
597
+ // move: single-clip persist, and NOT a single z patch or history entry — even
598
+ // with the z-sync deps fully wired and time-overlapping neighbours present.
599
+ const fixture = (): TimelineElement[] => [
600
+ {
601
+ id: "v-moodboard",
602
+ key: "v-moodboard",
603
+ tag: "video",
604
+ start: 19,
605
+ duration: 5.5,
606
+ track: 0,
607
+ zIndex: 37,
608
+ domId: "v-moodboard",
609
+ },
610
+ {
611
+ id: "v-dashboard",
612
+ key: "v-dashboard",
613
+ tag: "video",
614
+ start: 19,
615
+ duration: 4,
616
+ track: 1,
617
+ zIndex: 16,
618
+ domId: "v-dashboard",
619
+ },
620
+ {
621
+ id: "v-globe",
622
+ key: "v-globe",
623
+ tag: "video",
624
+ start: 23,
625
+ duration: 1.5,
626
+ track: 1,
627
+ zIndex: 17,
628
+ domId: "v-globe",
629
+ },
630
+ {
631
+ id: "cap",
632
+ key: "cap",
633
+ tag: "text",
634
+ start: 20.82,
635
+ duration: 1.78,
636
+ track: 2,
637
+ zIndex: 0,
638
+ domId: "cap",
639
+ },
640
+ ];
641
+
642
+ it("+2s on its own lane → single onMoveElement, zero stacking patches", async () => {
643
+ const elements = fixture();
644
+ const z: Record<string, number> = {
645
+ "v-moodboard": 37,
646
+ "v-dashboard": 16,
647
+ "v-globe": 17,
648
+ };
649
+ const onStackingPatches = vi.fn();
650
+ // previewTrack === element.track (0) and insertRow null → pure time move.
651
+ const { onMoveElement, onMoveElements } = runClipMove(
652
+ drag(elements[0], { previewStart: 21, previewTrack: 0 }),
653
+ {
654
+ elements,
655
+ trackOrder: [0, 1, 2],
656
+ readZIndex: (e) => z[e.key ?? e.id] ?? 0,
657
+ onStackingPatches,
658
+ },
659
+ );
660
+ await flushMicrotasks();
661
+ expect(onMoveElement).toHaveBeenCalledTimes(1);
662
+ expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 21, track: 0 });
663
+ expect(onMoveElements).not.toHaveBeenCalled();
664
+ expect(onStackingPatches).not.toHaveBeenCalled(); // zero z patches / history entries
665
+ });
666
+
667
+ it("guard: a topology call that AIMS at the clip's own display lane issues no z nudge", async () => {
668
+ // Belt-and-suspenders: even if a spurious insert whose boundary equals the
669
+ // clip's own lane slips into the topology branch, aiming at the current lane
670
+ // must never restack (syncStackingForEdit's aimedLane === currentLane no-op).
671
+ const elements = fixture();
672
+ const z: Record<string, number> = {
673
+ "v-moodboard": 37,
674
+ "v-dashboard": 16,
675
+ "v-globe": 17,
676
+ };
677
+ const onStackingPatches = vi.fn();
678
+ // insertRow 0 === v-moodboard's own display lane (0).
679
+ runClipMove(drag(elements[0], { previewStart: 21, previewTrack: 0, insertRow: 0 }), {
680
+ elements,
681
+ trackOrder: [0, 1, 2],
682
+ readZIndex: (e) => z[e.key ?? e.id] ?? 0,
683
+ onStackingPatches,
684
+ });
685
+ await flushMicrotasks();
686
+ expect(onStackingPatches).not.toHaveBeenCalled();
687
+ });
688
+ });
689
+
690
+ describe("horizontal drag among overlapping neighbours touches exactly ONE clip (live repro)", () => {
691
+ // The disease: a plain horizontal drag of one caption rewrote its own track,
692
+ // added a z-index, and rewrote FOUR other clips' track-indexes. The cure: a
693
+ // horizontal move writes ONLY the dragged clip's start — no other clip's
694
+ // start/track, no z — even surrounded by time-overlapping clips.
695
+ it("writes only the dragged clip's start; no other clip, no z", async () => {
696
+ const elements: TimelineElement[] = [
697
+ {
698
+ id: "cap",
699
+ key: "cap",
700
+ tag: "text",
701
+ start: 4.5,
702
+ duration: 1.2,
703
+ track: 0,
704
+ zIndex: 26,
705
+ domId: "cap",
706
+ },
707
+ {
708
+ id: "n1",
709
+ key: "n1",
710
+ tag: "video",
711
+ start: 4,
712
+ duration: 3,
713
+ track: 1,
714
+ zIndex: 12,
715
+ domId: "n1",
716
+ },
717
+ {
718
+ id: "n2",
719
+ key: "n2",
720
+ tag: "video",
721
+ start: 4,
722
+ duration: 3,
723
+ track: 2,
724
+ zIndex: 19,
725
+ domId: "n2",
726
+ },
727
+ {
728
+ id: "n3",
729
+ key: "n3",
730
+ tag: "text",
731
+ start: 5,
732
+ duration: 2,
733
+ track: 3,
734
+ zIndex: 25,
735
+ domId: "n3",
736
+ },
737
+ ];
738
+ const onStackingPatches = vi.fn();
739
+ // Pure horizontal: previewTrack === element.track (0), no insert.
740
+ const { updateElement, onMoveElement, onMoveElements } = runClipMove(
741
+ drag(elements[0], {
742
+ previewStart: 5.5,
743
+ previewTrack: 0,
744
+ desiredTrack: 0,
745
+ }),
746
+ {
747
+ elements,
748
+ trackOrder: [0, 1, 2, 3],
749
+ readZIndex: (e) => (e.zIndex as number) ?? 0,
750
+ onStackingPatches,
751
+ },
752
+ );
753
+ await flushMicrotasks();
754
+ // Exactly one clip written, start only, no z entry.
755
+ expect(updateElement).toHaveBeenCalledTimes(1);
756
+ expect(updateElement).toHaveBeenCalledWith("cap", {
757
+ start: 5.5,
758
+ track: 0,
759
+ });
760
+ expect(onMoveElement).toHaveBeenCalledTimes(1);
761
+ expect(onMoveElements).not.toHaveBeenCalled();
762
+ expect(onStackingPatches).not.toHaveBeenCalled();
763
+ });
764
+ });
765
+
766
+ describe("horizontal collision relocation (ITEM 2 — dragged clip only, never z)", () => {
767
+ it("a horizontal drag bumped to a free lane writes ONLY the dragged clip, and never z", async () => {
768
+ // The pointer stayed on lane 0 (desiredTrack === element.track), but the
769
+ // target span was occupied there, so the collision rules relocated the DRAGGED
770
+ // clip to lane 1 (previewTrack 1). That is not a deliberate vertical move:
771
+ // persist just the dragged clip (new start + relocated lane), rewrite no other
772
+ // clip, and issue zero z patches even though it now overlaps a neighbour.
773
+ const elements: TimelineElement[] = [
774
+ {
775
+ id: "a",
776
+ key: "a",
777
+ tag: "video",
778
+ start: 0,
779
+ duration: 5,
780
+ track: 0,
781
+ zIndex: 2,
782
+ domId: "a",
783
+ },
784
+ {
785
+ id: "d",
786
+ key: "d",
787
+ tag: "video",
788
+ start: 0,
789
+ duration: 5,
790
+ track: 2,
791
+ zIndex: 0,
792
+ domId: "d",
793
+ },
794
+ ];
795
+ const onStackingPatches = vi.fn();
796
+ const { onMoveElements } = runClipMove(
797
+ // desiredTrack 0 (pointer never left lane 0) but previewTrack 1 (bumped).
798
+ drag(elements[0], {
799
+ previewStart: 2,
800
+ previewTrack: 1,
801
+ desiredTrack: 0,
802
+ }),
803
+ {
804
+ elements,
805
+ trackOrder: [0, 1, 2],
806
+ readZIndex: (e) => (e.zIndex as number) ?? 0,
807
+ onStackingPatches,
808
+ },
809
+ );
810
+ await flushMicrotasks();
811
+ const map = editMap(onMoveElements.mock.calls[0][0]);
812
+ expect(map.a).toEqual({ start: 2, track: 1 }); // dragged clip relocated
813
+ expect(map.d).toBeUndefined(); // untouched
814
+ expect(onStackingPatches).not.toHaveBeenCalled(); // horizontal → never z
815
+ });
816
+ });
817
+
818
+ describe("capabilities gate (ITEM 2)", () => {
819
+ const locked = (
820
+ id: string,
821
+ track: number,
822
+ start: number,
823
+ duration: number,
824
+ ): TimelineElement => ({
825
+ ...el(id, track, start, duration),
826
+ timelineLocked: true,
827
+ });
828
+
829
+ it("a marquee containing a locked clip never persists an edit for the locked clip", () => {
830
+ const dragged = el("a", 0, 2, 3);
831
+ const elements = [dragged, locked("b", 1, 10, 3)];
832
+ // Pure time-move +5 on the same lane while {a, b} are marquee-selected.
833
+ const { map } = runMarqueeMove(dragged, 7, elements);
834
+ expect(map.a).toEqual({ start: 7, track: 0 });
835
+ expect(map.b).toBeUndefined(); // locked → filtered out of the moving set
836
+ });
837
+
838
+ it("a plain lane change never persists a locked neighbour (only the dragged clip is written)", async () => {
839
+ const dragged = el("a", 0, 0, 3);
840
+ const elements = [dragged, locked("b", 1, 0, 3)];
841
+ // Drag a onto b's lane. A plain lane change writes ONLY the dragged clip, so
842
+ // the locked neighbour b is inherently untouched (never even a candidate).
843
+ const { onMoveElements } = runClipMove(drag(dragged, { previewStart: 0, previewTrack: 1 }), {
844
+ elements,
845
+ trackOrder: [0, 1],
846
+ });
847
+ await flushMicrotasks();
848
+ const map = editMap(onMoveElements.mock.calls[0][0]);
849
+ expect(map.a).toBeDefined(); // movable dragged clip persists
850
+ expect(map.b).toBeUndefined(); // locked clip receives no patch
851
+ });
852
+
853
+ it("a lane change that produces ZERO move edits fires no z-sync (no orphaned z entry, ITEM 5)", async () => {
854
+ // Every clip in the set is locked (including the dragged one), so the moving
855
+ // set filters down to nothing. persistMoveEdits resolves true for the empty
856
+ // batch, but the z-sync must NOT fire — otherwise a "Reorder layers" history
857
+ // entry lands with no corresponding move.
858
+ const dragged = locked("a", 0, 0, 5);
859
+ const elements = [dragged, locked("b", 0, 0, 5)];
860
+ const onMoveElements = vi.fn();
861
+ const onStackingPatches = vi.fn();
862
+ // Drag onto lane 1 (a topology change) so the lane-change branch runs.
863
+ commitDraggedClipMove(drag(dragged, { previewStart: 0, previewTrack: 1, insertRow: 0 }), {
864
+ elements,
865
+ trackOrder: [0],
866
+ updateElement: vi.fn(),
867
+ onMoveElement: vi.fn(),
868
+ onMoveElements,
869
+ readZIndex: () => 0,
870
+ onStackingPatches,
871
+ });
872
+ await flushMicrotasks();
873
+ expect(onMoveElements).not.toHaveBeenCalled(); // empty batch → no persist call
874
+ expect(onStackingPatches).not.toHaveBeenCalled(); // and no orphaned z entry
875
+ });
876
+ });
877
+
878
+ describe("z-sync serialization + rollback (ITEM 3)", () => {
879
+ it("defers the z-sync until the move persist resolves (no clobbering pre-write)", async () => {
880
+ const elements = overlapping();
881
+ let resolveMove!: () => void;
882
+ const onMoveElements = vi.fn(() => new Promise<void>((r) => (resolveMove = r)));
883
+ const onStackingPatches = vi.fn();
884
+ commitInsertAbove(elements, { onMoveElements, onStackingPatches });
885
+ await flushMicrotasks();
886
+ // Move persist still pending → the z patch must NOT have been issued yet.
887
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
888
+ expect(onStackingPatches).not.toHaveBeenCalled();
889
+ resolveMove();
890
+ await flushMicrotasks();
891
+ // Only after the write lands does the z patch fire — once, for the edited clip.
892
+ expectZLiftedToSix(onStackingPatches);
893
+ });
894
+
895
+ it("rolls back the move and skips the z-sync when the persist fails", async () => {
896
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
897
+ const elements = overlapping();
898
+ const onMoveElements = vi.fn(() => Promise.reject(new Error("write failed")));
899
+ const onStackingPatches = vi.fn();
900
+ const updateElement = vi.fn();
901
+ commitInsertAbove(elements, {
902
+ updateElement,
903
+ onMoveElements,
904
+ onStackingPatches,
905
+ });
906
+ await flushMicrotasks();
907
+ // Failed move → z patch never issued (no orphaned z change left behind)...
908
+ expect(onStackingPatches).not.toHaveBeenCalled();
909
+ // ...and the optimistic start/track edit for the dragged clip is rolled back.
910
+ expect(updateElement).toHaveBeenCalledWith("a", { start: 0, track: 1 });
911
+ errSpy.mockRestore();
912
+ });
913
+
914
+ it("does not let an older rejected move roll back a newer optimistic gesture", async () => {
915
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
916
+ const element = el("a", 0, 0, 5);
917
+ let rejectFirst!: (error: Error) => void;
918
+ const onMoveElement = vi
919
+ .fn()
920
+ .mockImplementationOnce(
921
+ () => new Promise<void>((_resolve, reject) => (rejectFirst = reject)),
922
+ )
923
+ .mockResolvedValueOnce(undefined);
924
+ const current = new Map([["a", { start: 0, track: 0 }]]);
925
+ const updateElement = vi.fn((key: string, updates: Partial<TimelineElement>) => {
926
+ current.set(key, { ...current.get(key)!, ...updates });
927
+ });
928
+ const deps = {
929
+ elements: [element],
930
+ trackOrder: [0],
931
+ updateElement,
932
+ onMoveElement,
933
+ };
934
+
935
+ commitDraggedClipMove(drag(element, { previewStart: 1, previewTrack: 0 }), deps);
936
+ commitDraggedClipMove(drag(element, { previewStart: 2, previewTrack: 0 }), deps);
937
+ await flushMicrotasks();
938
+ rejectFirst(new Error("older write failed"));
939
+ await flushMicrotasks();
940
+
941
+ expect(current.get("a")).toEqual({ start: 2, track: 0 });
942
+ errorSpy.mockRestore();
943
+ });
944
+ });
945
+
946
+ describe("lane-change undo coalescing (ITEM 3c)", () => {
947
+ const commitLaneChange = (elements: TimelineElement[]) => {
948
+ const onMoveElements = vi.fn();
949
+ const onStackingPatches = vi.fn();
950
+ commitInsertAbove(elements, { onMoveElements, onStackingPatches });
951
+ return { onMoveElements, onStackingPatches };
952
+ };
953
+
954
+ it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the two records merge into a single undo entry", async () => {
955
+ const { onMoveElements, onStackingPatches } = commitLaneChange(overlapping());
956
+ await flushMicrotasks();
957
+
958
+ // Both sides receive the SAME non-empty gesture key (second arg).
959
+ const moveKey = onMoveElements.mock.calls[0][1];
960
+ const zKey = onStackingPatches.mock.calls[0][1];
961
+ expect(typeof moveKey).toBe("string");
962
+ expect(moveKey).not.toBe("");
963
+ expect(zKey).toBe(moveKey);
964
+
965
+ // With that shared key, editHistory folds the two consecutive records (the
966
+ // "Move timeline clips" write + the "Reorder layers" z patch, same file,
967
+ // inside the coalesce window) into ONE undo entry spanning before→after.
968
+ const now = 1_000;
969
+ const moveEntry = buildEditHistoryEntry({
970
+ id: "m",
971
+ projectId: "p",
972
+ label: "Move timeline clips",
973
+ kind: "timeline",
974
+ coalesceKey: moveKey,
975
+ now,
976
+ files: { "index.html": { before: "<v0>", after: "<v1>" } },
977
+ });
978
+ const zEntry = buildEditHistoryEntry({
979
+ id: "z",
980
+ projectId: "p",
981
+ label: "Reorder layers",
982
+ kind: "timeline",
983
+ coalesceKey: zKey,
984
+ now: now + 50,
985
+ files: { "index.html": { before: "<v1>", after: "<v2>" } },
986
+ });
987
+ const state = pushEditHistoryEntry(
988
+ pushEditHistoryEntry(createEmptyEditHistory(), moveEntry),
989
+ zEntry,
990
+ );
991
+ expect(state.undo).toHaveLength(1);
992
+ expect(state.undo[0].files["index.html"]).toMatchObject({
993
+ before: "<v0>",
994
+ after: "<v2>",
995
+ });
996
+ });
997
+
998
+ it("distinct gestures get distinct keys (independent moves never cross-merge)", async () => {
999
+ const { onMoveElements: first } = commitLaneChange(overlapping());
1000
+ const { onMoveElements: second } = commitLaneChange(overlapping());
1001
+ await flushMicrotasks();
1002
+ expect(first.mock.calls[0][1]).not.toBe(second.mock.calls[0][1]);
1003
+ });
1004
+
1005
+ it("a plain move that issues no z patch persists once and records no second entry (unchanged)", async () => {
1006
+ // a & b do NOT overlap in time → the z-sync produces zero patches.
1007
+ const elements = [el("a", 1, 0, 5), el("b", 0, 10, 5)];
1008
+ const onMoveElements = vi.fn();
1009
+ const onStackingPatches = vi.fn();
1010
+ commitDraggedClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), {
1011
+ elements,
1012
+ trackOrder: [0, 1],
1013
+ updateElement: vi.fn(),
1014
+ onMoveElement: vi.fn(),
1015
+ onMoveElements,
1016
+ readZIndex: () => 0,
1017
+ onStackingPatches,
1018
+ });
1019
+ await flushMicrotasks();
1020
+ // Move persists exactly once; no z entry is created → nothing to merge, so
1021
+ // the single "Move timeline clips" entry stands alone as before.
1022
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
1023
+ expect(onStackingPatches).not.toHaveBeenCalled();
1024
+ });
1025
+ });
1026
+ });