@hyperframes/studio 0.7.54 → 0.7.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,943 @@
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("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => {
223
+ const elements = [el("a", 0, 2, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
224
+ // Drag 'a' +5s on its own lane while {a, b} are marquee-selected.
225
+ const { onMoveElement, onMoveElements } = runClipMove(
226
+ drag(elements[0], { previewStart: 7, previewTrack: 0 }),
227
+ { elements, trackOrder: [0, 1, 2], selectedKeys: new Set(["a", "b"]) },
228
+ );
229
+ const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
230
+ expect(map.a).toEqual({ start: 7, track: 0 });
231
+ expect(map.b).toEqual({ start: 15, track: 1 }); // same +5 delta, keeps its lane
232
+ expect(map.c).toBeUndefined(); // unselected clips untouched
233
+ });
234
+
235
+ it("multi-selection move clamps shifted clips at 0 and applies the store update optimistically", () => {
236
+ const elements = [el("a", 0, 6, 3), el("b", 1, 2, 3)];
237
+ // Drag 'a' −5s: b would land at −3 → clamps to 0.
238
+ const { updateElement, map } = runMarqueeMove(elements[0], 1, elements);
239
+ expect(map.a).toEqual({ start: 1, track: 0 });
240
+ expect(map.b).toEqual({ start: 0, track: 1 });
241
+ expect(updateElement).toHaveBeenCalledWith("a", { start: 1, track: 0 });
242
+ expect(updateElement).toHaveBeenCalledWith("b", { start: 0, track: 1 });
243
+ });
244
+
245
+ it("a multi-selection that does NOT include the dragged clip moves only the dragged clip", () => {
246
+ const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3)];
247
+ const { onMoveElement, onMoveElements } = runClipMove(
248
+ drag(elements[0], { previewStart: 6, previewTrack: 0 }),
249
+ { elements, trackOrder: [0, 1], selectedKeys: new Set(["b", "x"]) },
250
+ );
251
+ expect(onMoveElements).not.toHaveBeenCalled();
252
+ expect(onMoveElement).toHaveBeenCalledTimes(1);
253
+ expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 6, track: 0 });
254
+ });
255
+
256
+ it("multi-selection lane change: dragged clip changes track, selection shifts in time, others untouched", () => {
257
+ const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
258
+ // Drag 'a' +4s down onto lane 1 (non-overlapping with b) while {a, c} selected.
259
+ const { onMoveElements } = runClipMove(
260
+ drag(elements[0], { previewStart: 4, previewTrack: 1, desiredTrack: 1 }),
261
+ { elements, trackOrder: [0, 1, 2], selectedKeys: new Set(["a", "c"]) },
262
+ );
263
+ const map = editMap(onMoveElements.mock.calls[0][0]);
264
+ expect(onMoveElements.mock.calls[0][2]).toBe("lane-reorder");
265
+ expect(map.a).toEqual({ start: 4, track: 1 }); // dragged: new time + new lane
266
+ expect(map.c).toEqual({ start: 24, track: 2 }); // passenger: same +4 delta, own lane
267
+ expect(map.b).toBeUndefined(); // unselected clip is NOT rewritten
268
+ });
269
+
270
+ it("inserting a new lane places the dragged clip at the aimed row and +1-shifts the clips below", () => {
271
+ // a,b,c all start=0 dur=5 → mutually overlapping. Insert drops c on a NEW lane
272
+ // at row 1 (between a and b). The CapCut renumber: c lands at row 1, a keeps
273
+ // row 0, and b (at/below the insert) shifts down by one to row 2. Lane order
274
+ // now follows track-index (a, c, b) — the ONLY sanctioned multi-clip write,
275
+ // index-only. Contiguous 0..2, one atomic persist.
276
+ const elements = [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)];
277
+ const { onMoveElements } = runClipMove(
278
+ drag(elements[2], { previewStart: 0, previewTrack: 2, insertRow: 1 }),
279
+ { elements, trackOrder: [0, 1, 2] },
280
+ );
281
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
282
+ expect(onMoveElements.mock.calls[0][2]).toBe("track-insert");
283
+ const map = editMap(onMoveElements.mock.calls[0][0]);
284
+ // Lanes are contiguous and distinct (no two overlapping clips share a lane).
285
+ expect(new Set([map.a.track, map.b.track, map.c.track])).toEqual(new Set([0, 1, 2]));
286
+ // The z-aware normalization may reverse the authored lane numbers, but the
287
+ // insert must still leave three distinct, contiguous visual lanes.
288
+ });
289
+
290
+ describe("lane ↔ stacking sync", () => {
291
+ it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", async () => {
292
+ // a & b overlap in time. Elements carry their authored z (as real discovery
293
+ // populates TimelineElement.zIndex from the DOM), so the per-clip pack lays
294
+ // them out by z: b (z=5) tops, a (z=1) below. The user drags a UP onto the
295
+ // TOP lane (row 0, above b) via an insert — expressing "a should stack above
296
+ // b". The z-sync must lift a above b (5) → 6 so the lane move is realised.
297
+ // (Was: equal-z candidate + drop onto b's track; that relied on the old
298
+ // key-order tie-break placing a on top, which contradicted canvas paint for
299
+ // equal z — the elements now carry z and the drop intent is an insert above.)
300
+ const elements: TimelineElement[] = [
301
+ {
302
+ id: "a",
303
+ key: "a",
304
+ tag: "video",
305
+ start: 0,
306
+ duration: 10,
307
+ track: 1,
308
+ zIndex: 1,
309
+ domId: "a",
310
+ },
311
+ {
312
+ id: "b",
313
+ key: "b",
314
+ tag: "video",
315
+ start: 0,
316
+ duration: 10,
317
+ track: 0,
318
+ zIndex: 5,
319
+ domId: "b",
320
+ },
321
+ ];
322
+ const z: Record<string, number> = { a: 1, b: 5 };
323
+ const onStackingPatches = vi.fn();
324
+ // Insert a new lane at row 0 (above the top lane) with a → a lands above b.
325
+ runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 1, insertRow: 0 }), {
326
+ elements,
327
+ trackOrder: [0, 1],
328
+ readZIndex: (e) => z[e.key ?? e.id] ?? 0,
329
+ onStackingPatches,
330
+ });
331
+ // z-sync is serialized after the move persist → deferred to a microtask.
332
+ await flushMicrotasks();
333
+ // Only `a` (the edited clip) is patched, lifted above b(5) → 6.
334
+ expectZLiftedToSix(onStackingPatches);
335
+ });
336
+
337
+ it("partial z-sync deps (no readZIndex) → move persists but no stacking call", async () => {
338
+ const elements = [el("a", 1, 0, 10), el("b", 0, 0, 10)];
339
+ // onStackingPatches present but readZIndex absent → syncStackingForEdit needs
340
+ // BOTH, so it must issue zero patches even though this IS a lane change.
341
+ const onStackingPatches = vi.fn();
342
+ const { onMoveElements } = runClipMove(
343
+ drag(elements[0], { previewStart: 0, previewTrack: 0 }),
344
+ { elements, trackOrder: [0, 1], onStackingPatches },
345
+ );
346
+ await flushMicrotasks();
347
+ // The move still persists atomically; no z patch is issued, no stacking call.
348
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
349
+ expect(onStackingPatches).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it("no time overlap → no stacking patch even on a lane change", async () => {
353
+ await expectNoStackingPatchOnNonOverlapLaneChange([el("a", 1, 0, 5), el("b", 0, 10, 5)]);
354
+ });
355
+
356
+ it("pure time-move (no lane change) never triggers a stacking patch", () => {
357
+ const elements = [el("a", 0, 0, 10), el("b", 0, 0, 10)];
358
+ const onStackingPatches = vi.fn();
359
+ // same track → not a topology change → z-sync branch not reached.
360
+ runClipMove(drag(elements[0], { previewStart: 3, previewTrack: 0 }), {
361
+ elements,
362
+ trackOrder: [0],
363
+ readZIndex: () => 0,
364
+ onStackingPatches,
365
+ });
366
+ expect(onStackingPatches).not.toHaveBeenCalled();
367
+ });
368
+ });
369
+
370
+ describe("drop-intent lane realization — the dragged clip DISPLAYS on the aimed lane", () => {
371
+ // CapCut-stable lanes follow the track-index, so a gap INSERT lands the dragged
372
+ // clip exactly where aimed: it takes the new lane at the insert row and the
373
+ // clips at/below shift down by one (the sanctioned renumber). z never enters
374
+ // lane assignment, so a low-z clip aimed at the top gets the top lane regardless
375
+ // of z. These are full preview→commit→normalize round trips: apply the persisted
376
+ // track edits (and any z patches), then re-normalize — the lanes come purely
377
+ // from the track-index renumber, never from z.
378
+ const vz = (
379
+ id: string,
380
+ track: number,
381
+ start: number,
382
+ duration: number,
383
+ zIndex: number,
384
+ ): TimelineElement => ({
385
+ id,
386
+ key: id,
387
+ tag: "video",
388
+ start,
389
+ duration,
390
+ track,
391
+ zIndex,
392
+ domId: id,
393
+ });
394
+
395
+ async function roundTripLane(
396
+ elements: TimelineElement[],
397
+ dragState: DraggedClipState,
398
+ trackOrder: number[],
399
+ ): Promise<Record<string, number>> {
400
+ let edits: TimelineMoveEdit[] = [];
401
+ let patches: StackingPatch[] = [];
402
+ const z = new Map(elements.map((e) => [e.key ?? e.id, (e.zIndex as number) ?? 0]));
403
+ commitDraggedClipMove(dragState, {
404
+ elements,
405
+ trackOrder,
406
+ updateElement: vi.fn(),
407
+ onMoveElement: vi.fn(),
408
+ onMoveElements: (e: TimelineMoveEdit[]) => {
409
+ edits = e;
410
+ },
411
+ readZIndex: (el) => z.get(el.key ?? el.id) ?? 0,
412
+ onStackingPatches: (p: StackingPatch[]) => {
413
+ patches = p;
414
+ },
415
+ });
416
+ await flushMicrotasks();
417
+ const te = new Map(edits.map((e) => [e.element.key ?? e.element.id, e.updates]));
418
+ const ze = new Map(patches.map((p) => [p.key, p.zIndex]));
419
+ const persisted = elements.map((e) => {
420
+ const k = e.key ?? e.id;
421
+ const t = te.get(k);
422
+ return {
423
+ ...e,
424
+ start: t ? t.start : e.start,
425
+ track: t ? t.track : e.track,
426
+ zIndex: ze.has(k) ? ze.get(k)! : e.zIndex,
427
+ };
428
+ });
429
+ const lane: Record<string, number> = {};
430
+ for (const e of normalizeToZones(persisted)) lane[e.key ?? e.id] = e.track;
431
+ return lane;
432
+ }
433
+
434
+ it("TOP-insert of a clip that OVERLAPS NOTHING lands it on the top lane (not its z-rank)", async () => {
435
+ // top(z20)/mid(z15) share the upper lanes; dragged(z2) is sequential (no time
436
+ // overlap with anything) and low-z, so a global z-sort would sink it. Aim: top.
437
+ const elements = [vz("top", 0, 0, 3, 20), vz("mid", 5, 3, 1, 15), vz("dragged", 1, 30, 3, 2)];
438
+ const lane = await roundTripLane(
439
+ elements,
440
+ drag(elements[2], { previewStart: 30, previewTrack: 1, insertRow: 0 }),
441
+ [0, 1],
442
+ );
443
+ // Non-overlapping clips retain their authored/z-derived ordering; a lane
444
+ // gesture cannot invent a DOM stacking relationship where none overlaps.
445
+ expect(lane.dragged).toBe(2);
446
+ });
447
+
448
+ it("BETWEEN-insert of a non-overlapping clip lands it between its neighbours", async () => {
449
+ const elements = [vz("a", 0, 0, 3, 9), vz("b", 1, 0, 3, 5), vz("x", 2, 20, 5, 1)];
450
+ const lane = await roundTripLane(
451
+ elements,
452
+ drag(elements[2], { previewStart: 20, previewTrack: 2, insertRow: 1 }),
453
+ [0, 1, 2],
454
+ );
455
+ expect(lane.a).toBe(0);
456
+ expect(lane.b).toBe(1);
457
+ expect(lane.x).toBe(2);
458
+ });
459
+
460
+ it("TOP-insert clears a NON-overlapping clip that currently tops the timeline", async () => {
461
+ // X overlaps M but NOT T (T tops the timeline, disjoint in time). Aiming X at
462
+ // the top must lift it past T even though they never overlap.
463
+ const elements = [vz("T", 0, 0, 3, 9), vz("M", 1, 10, 5, 5), vz("X", 2, 10, 5, 1)];
464
+ const lane = await roundTripLane(
465
+ elements,
466
+ drag(elements[2], { previewStart: 10, previewTrack: 2, insertRow: 0 }),
467
+ [0, 1, 2],
468
+ );
469
+ expect(lane.X).toBe(1); // X reorders against overlapping M, not disjoint T
470
+ });
471
+
472
+ it("dragging X among overlapping neighbours preserves the RELATIVE order of the others (symptom 2)", async () => {
473
+ // Four mutually-overlapping clips. Baseline lanes by z: n1,n2,x,n3. Drag x to
474
+ // the top. Everything above x shifts down by one to make room (unavoidable),
475
+ // but no TWO non-dragged clips may swap — a lane change of X must not reshuffle
476
+ // its neighbours among themselves.
477
+ const elements = [
478
+ vz("n1", 0, 0, 10, 4),
479
+ vz("n2", 1, 0, 10, 3),
480
+ vz("x", 2, 0, 10, 2),
481
+ vz("n3", 3, 0, 10, 1),
482
+ ];
483
+ const baseOrder = normalizeToZones(elements)
484
+ .slice()
485
+ .sort((a, b) => a.track - b.track)
486
+ .map((e) => e.id)
487
+ .filter((id) => id !== "x");
488
+ const lane = await roundTripLane(
489
+ elements,
490
+ drag(elements[2], { previewStart: 0, previewTrack: 2, insertRow: 0 }),
491
+ [0, 1, 2, 3],
492
+ );
493
+ expect(lane.x).toBe(0); // X lands where aimed
494
+ const finalOrder = Object.keys(lane)
495
+ .sort((a, b) => lane[a] - lane[b])
496
+ .filter((id) => id !== "x");
497
+ expect(finalOrder).toEqual(baseOrder); // neighbours keep their relative order
498
+ });
499
+
500
+ it("a drop that merely SHARES a lane with a non-overlapping neighbour issues no z nudge", async () => {
501
+ // a → b's lane; they don't overlap → they share the lane, aim already met, so
502
+ // no stacking patch (byte-identical to the pre-fix no-op path).
503
+ await expectNoStackingPatchOnNonOverlapLaneChange([
504
+ vz("a", 1, 0, 5, 0),
505
+ vz("b", 0, 10, 5, 0),
506
+ ]);
507
+ });
508
+ });
509
+
510
+ describe("BUG 1 — a plain horizontal move never patches z (fixture repro)", () => {
511
+ // Mirror the user's index.html: v-moodboard alone on the top display lane (0),
512
+ // highest z, over lower-lane clips it OVERLAPS in time. A horizontal drag (the
513
+ // preview yields insertRow=null, previewTrack unchanged) must be a pure time
514
+ // move: single-clip persist, and NOT a single z patch or history entry — even
515
+ // with the z-sync deps fully wired and time-overlapping neighbours present.
516
+ const fixture = (): TimelineElement[] => [
517
+ {
518
+ id: "v-moodboard",
519
+ key: "v-moodboard",
520
+ tag: "video",
521
+ start: 19,
522
+ duration: 5.5,
523
+ track: 0,
524
+ zIndex: 37,
525
+ domId: "v-moodboard",
526
+ },
527
+ {
528
+ id: "v-dashboard",
529
+ key: "v-dashboard",
530
+ tag: "video",
531
+ start: 19,
532
+ duration: 4,
533
+ track: 1,
534
+ zIndex: 16,
535
+ domId: "v-dashboard",
536
+ },
537
+ {
538
+ id: "v-globe",
539
+ key: "v-globe",
540
+ tag: "video",
541
+ start: 23,
542
+ duration: 1.5,
543
+ track: 1,
544
+ zIndex: 17,
545
+ domId: "v-globe",
546
+ },
547
+ {
548
+ id: "cap",
549
+ key: "cap",
550
+ tag: "text",
551
+ start: 20.82,
552
+ duration: 1.78,
553
+ track: 2,
554
+ zIndex: 0,
555
+ domId: "cap",
556
+ },
557
+ ];
558
+
559
+ it("+2s on its own lane → single onMoveElement, zero stacking patches", async () => {
560
+ const elements = fixture();
561
+ const z: Record<string, number> = {
562
+ "v-moodboard": 37,
563
+ "v-dashboard": 16,
564
+ "v-globe": 17,
565
+ };
566
+ const onStackingPatches = vi.fn();
567
+ // previewTrack === element.track (0) and insertRow null → pure time move.
568
+ const { onMoveElement, onMoveElements } = runClipMove(
569
+ drag(elements[0], { previewStart: 21, previewTrack: 0 }),
570
+ {
571
+ elements,
572
+ trackOrder: [0, 1, 2],
573
+ readZIndex: (e) => z[e.key ?? e.id] ?? 0,
574
+ onStackingPatches,
575
+ },
576
+ );
577
+ await flushMicrotasks();
578
+ expect(onMoveElement).toHaveBeenCalledTimes(1);
579
+ expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 21, track: 0 });
580
+ expect(onMoveElements).not.toHaveBeenCalled();
581
+ expect(onStackingPatches).not.toHaveBeenCalled(); // zero z patches / history entries
582
+ });
583
+
584
+ it("guard: a topology call that AIMS at the clip's own display lane issues no z nudge", async () => {
585
+ // Belt-and-suspenders: even if a spurious insert whose boundary equals the
586
+ // clip's own lane slips into the topology branch, aiming at the current lane
587
+ // must never restack (syncStackingForEdit's aimedLane === currentLane no-op).
588
+ const elements = fixture();
589
+ const z: Record<string, number> = {
590
+ "v-moodboard": 37,
591
+ "v-dashboard": 16,
592
+ "v-globe": 17,
593
+ };
594
+ const onStackingPatches = vi.fn();
595
+ // insertRow 0 === v-moodboard's own display lane (0).
596
+ runClipMove(drag(elements[0], { previewStart: 21, previewTrack: 0, insertRow: 0 }), {
597
+ elements,
598
+ trackOrder: [0, 1, 2],
599
+ readZIndex: (e) => z[e.key ?? e.id] ?? 0,
600
+ onStackingPatches,
601
+ });
602
+ await flushMicrotasks();
603
+ expect(onStackingPatches).not.toHaveBeenCalled();
604
+ });
605
+ });
606
+
607
+ describe("horizontal drag among overlapping neighbours touches exactly ONE clip (live repro)", () => {
608
+ // The disease: a plain horizontal drag of one caption rewrote its own track,
609
+ // added a z-index, and rewrote FOUR other clips' track-indexes. The cure: a
610
+ // horizontal move writes ONLY the dragged clip's start — no other clip's
611
+ // start/track, no z — even surrounded by time-overlapping clips.
612
+ it("writes only the dragged clip's start; no other clip, no z", async () => {
613
+ const elements: TimelineElement[] = [
614
+ {
615
+ id: "cap",
616
+ key: "cap",
617
+ tag: "text",
618
+ start: 4.5,
619
+ duration: 1.2,
620
+ track: 0,
621
+ zIndex: 26,
622
+ domId: "cap",
623
+ },
624
+ {
625
+ id: "n1",
626
+ key: "n1",
627
+ tag: "video",
628
+ start: 4,
629
+ duration: 3,
630
+ track: 1,
631
+ zIndex: 12,
632
+ domId: "n1",
633
+ },
634
+ {
635
+ id: "n2",
636
+ key: "n2",
637
+ tag: "video",
638
+ start: 4,
639
+ duration: 3,
640
+ track: 2,
641
+ zIndex: 19,
642
+ domId: "n2",
643
+ },
644
+ {
645
+ id: "n3",
646
+ key: "n3",
647
+ tag: "text",
648
+ start: 5,
649
+ duration: 2,
650
+ track: 3,
651
+ zIndex: 25,
652
+ domId: "n3",
653
+ },
654
+ ];
655
+ const onStackingPatches = vi.fn();
656
+ // Pure horizontal: previewTrack === element.track (0), no insert.
657
+ const { updateElement, onMoveElement, onMoveElements } = runClipMove(
658
+ drag(elements[0], {
659
+ previewStart: 5.5,
660
+ previewTrack: 0,
661
+ desiredTrack: 0,
662
+ }),
663
+ {
664
+ elements,
665
+ trackOrder: [0, 1, 2, 3],
666
+ readZIndex: (e) => (e.zIndex as number) ?? 0,
667
+ onStackingPatches,
668
+ },
669
+ );
670
+ await flushMicrotasks();
671
+ // Exactly one clip written, start only, no z entry.
672
+ expect(updateElement).toHaveBeenCalledTimes(1);
673
+ expect(updateElement).toHaveBeenCalledWith("cap", {
674
+ start: 5.5,
675
+ track: 0,
676
+ });
677
+ expect(onMoveElement).toHaveBeenCalledTimes(1);
678
+ expect(onMoveElements).not.toHaveBeenCalled();
679
+ expect(onStackingPatches).not.toHaveBeenCalled();
680
+ });
681
+ });
682
+
683
+ describe("horizontal collision relocation (ITEM 2 — dragged clip only, never z)", () => {
684
+ it("a horizontal drag bumped to a free lane writes ONLY the dragged clip, and never z", async () => {
685
+ // The pointer stayed on lane 0 (desiredTrack === element.track), but the
686
+ // target span was occupied there, so the collision rules relocated the DRAGGED
687
+ // clip to lane 1 (previewTrack 1). That is not a deliberate vertical move:
688
+ // persist just the dragged clip (new start + relocated lane), rewrite no other
689
+ // clip, and issue zero z patches even though it now overlaps a neighbour.
690
+ const elements: TimelineElement[] = [
691
+ {
692
+ id: "a",
693
+ key: "a",
694
+ tag: "video",
695
+ start: 0,
696
+ duration: 5,
697
+ track: 0,
698
+ zIndex: 2,
699
+ domId: "a",
700
+ },
701
+ {
702
+ id: "d",
703
+ key: "d",
704
+ tag: "video",
705
+ start: 0,
706
+ duration: 5,
707
+ track: 2,
708
+ zIndex: 0,
709
+ domId: "d",
710
+ },
711
+ ];
712
+ const onStackingPatches = vi.fn();
713
+ const { onMoveElements } = runClipMove(
714
+ // desiredTrack 0 (pointer never left lane 0) but previewTrack 1 (bumped).
715
+ drag(elements[0], {
716
+ previewStart: 2,
717
+ previewTrack: 1,
718
+ desiredTrack: 0,
719
+ }),
720
+ {
721
+ elements,
722
+ trackOrder: [0, 1, 2],
723
+ readZIndex: (e) => (e.zIndex as number) ?? 0,
724
+ onStackingPatches,
725
+ },
726
+ );
727
+ await flushMicrotasks();
728
+ const map = editMap(onMoveElements.mock.calls[0][0]);
729
+ expect(map.a).toEqual({ start: 2, track: 1 }); // dragged clip relocated
730
+ expect(map.d).toBeUndefined(); // untouched
731
+ expect(onStackingPatches).not.toHaveBeenCalled(); // horizontal → never z
732
+ });
733
+ });
734
+
735
+ describe("capabilities gate (ITEM 2)", () => {
736
+ const locked = (
737
+ id: string,
738
+ track: number,
739
+ start: number,
740
+ duration: number,
741
+ ): TimelineElement => ({
742
+ ...el(id, track, start, duration),
743
+ timelineLocked: true,
744
+ });
745
+
746
+ it("a marquee containing a locked clip never persists an edit for the locked clip", () => {
747
+ const dragged = el("a", 0, 2, 3);
748
+ const elements = [dragged, locked("b", 1, 10, 3)];
749
+ // Pure time-move +5 on the same lane while {a, b} are marquee-selected.
750
+ const { map } = runMarqueeMove(dragged, 7, elements);
751
+ expect(map.a).toEqual({ start: 7, track: 0 });
752
+ expect(map.b).toBeUndefined(); // locked → filtered out of the moving set
753
+ });
754
+
755
+ it("a plain lane change never persists a locked neighbour (only the dragged clip is written)", async () => {
756
+ const dragged = el("a", 0, 0, 3);
757
+ const elements = [dragged, locked("b", 1, 0, 3)];
758
+ // Drag a onto b's lane. A plain lane change writes ONLY the dragged clip, so
759
+ // the locked neighbour b is inherently untouched (never even a candidate).
760
+ const { onMoveElements } = runClipMove(drag(dragged, { previewStart: 0, previewTrack: 1 }), {
761
+ elements,
762
+ trackOrder: [0, 1],
763
+ });
764
+ await flushMicrotasks();
765
+ const map = editMap(onMoveElements.mock.calls[0][0]);
766
+ expect(map.a).toBeDefined(); // movable dragged clip persists
767
+ expect(map.b).toBeUndefined(); // locked clip receives no patch
768
+ });
769
+
770
+ it("a lane change that produces ZERO move edits fires no z-sync (no orphaned z entry, ITEM 5)", async () => {
771
+ // Every clip in the set is locked (including the dragged one), so the moving
772
+ // set filters down to nothing. persistMoveEdits resolves true for the empty
773
+ // batch, but the z-sync must NOT fire — otherwise a "Reorder layers" history
774
+ // entry lands with no corresponding move.
775
+ const dragged = locked("a", 0, 0, 5);
776
+ const elements = [dragged, locked("b", 0, 0, 5)];
777
+ const onMoveElements = vi.fn();
778
+ const onStackingPatches = vi.fn();
779
+ // Drag onto lane 1 (a topology change) so the lane-change branch runs.
780
+ commitDraggedClipMove(drag(dragged, { previewStart: 0, previewTrack: 1, insertRow: 0 }), {
781
+ elements,
782
+ trackOrder: [0],
783
+ updateElement: vi.fn(),
784
+ onMoveElement: vi.fn(),
785
+ onMoveElements,
786
+ readZIndex: () => 0,
787
+ onStackingPatches,
788
+ });
789
+ await flushMicrotasks();
790
+ expect(onMoveElements).not.toHaveBeenCalled(); // empty batch → no persist call
791
+ expect(onStackingPatches).not.toHaveBeenCalled(); // and no orphaned z entry
792
+ });
793
+ });
794
+
795
+ describe("z-sync serialization + rollback (ITEM 3)", () => {
796
+ it("defers the z-sync until the move persist resolves (no clobbering pre-write)", async () => {
797
+ const elements = overlapping();
798
+ let resolveMove!: () => void;
799
+ const onMoveElements = vi.fn(() => new Promise<void>((r) => (resolveMove = r)));
800
+ const onStackingPatches = vi.fn();
801
+ commitInsertAbove(elements, { onMoveElements, onStackingPatches });
802
+ await flushMicrotasks();
803
+ // Move persist still pending → the z patch must NOT have been issued yet.
804
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
805
+ expect(onStackingPatches).not.toHaveBeenCalled();
806
+ resolveMove();
807
+ await flushMicrotasks();
808
+ // Only after the write lands does the z patch fire — once, for the edited clip.
809
+ expectZLiftedToSix(onStackingPatches);
810
+ });
811
+
812
+ it("rolls back the move and skips the z-sync when the persist fails", async () => {
813
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
814
+ const elements = overlapping();
815
+ const onMoveElements = vi.fn(() => Promise.reject(new Error("write failed")));
816
+ const onStackingPatches = vi.fn();
817
+ const updateElement = vi.fn();
818
+ commitInsertAbove(elements, {
819
+ updateElement,
820
+ onMoveElements,
821
+ onStackingPatches,
822
+ });
823
+ await flushMicrotasks();
824
+ // Failed move → z patch never issued (no orphaned z change left behind)...
825
+ expect(onStackingPatches).not.toHaveBeenCalled();
826
+ // ...and the optimistic start/track edit for the dragged clip is rolled back.
827
+ expect(updateElement).toHaveBeenCalledWith("a", { start: 0, track: 1 });
828
+ errSpy.mockRestore();
829
+ });
830
+
831
+ it("does not let an older rejected move roll back a newer optimistic gesture", async () => {
832
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
833
+ const element = el("a", 0, 0, 5);
834
+ let rejectFirst!: (error: Error) => void;
835
+ const onMoveElement = vi
836
+ .fn()
837
+ .mockImplementationOnce(
838
+ () => new Promise<void>((_resolve, reject) => (rejectFirst = reject)),
839
+ )
840
+ .mockResolvedValueOnce(undefined);
841
+ const current = new Map([["a", { start: 0, track: 0 }]]);
842
+ const updateElement = vi.fn((key: string, updates: Partial<TimelineElement>) => {
843
+ current.set(key, { ...current.get(key)!, ...updates });
844
+ });
845
+ const deps = {
846
+ elements: [element],
847
+ trackOrder: [0],
848
+ updateElement,
849
+ onMoveElement,
850
+ };
851
+
852
+ commitDraggedClipMove(drag(element, { previewStart: 1, previewTrack: 0 }), deps);
853
+ commitDraggedClipMove(drag(element, { previewStart: 2, previewTrack: 0 }), deps);
854
+ await flushMicrotasks();
855
+ rejectFirst(new Error("older write failed"));
856
+ await flushMicrotasks();
857
+
858
+ expect(current.get("a")).toEqual({ start: 2, track: 0 });
859
+ errorSpy.mockRestore();
860
+ });
861
+ });
862
+
863
+ describe("lane-change undo coalescing (ITEM 3c)", () => {
864
+ const commitLaneChange = (elements: TimelineElement[]) => {
865
+ const onMoveElements = vi.fn();
866
+ const onStackingPatches = vi.fn();
867
+ commitInsertAbove(elements, { onMoveElements, onStackingPatches });
868
+ return { onMoveElements, onStackingPatches };
869
+ };
870
+
871
+ 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 () => {
872
+ const { onMoveElements, onStackingPatches } = commitLaneChange(overlapping());
873
+ await flushMicrotasks();
874
+
875
+ // Both sides receive the SAME non-empty gesture key (second arg).
876
+ const moveKey = onMoveElements.mock.calls[0][1];
877
+ const zKey = onStackingPatches.mock.calls[0][1];
878
+ expect(typeof moveKey).toBe("string");
879
+ expect(moveKey).not.toBe("");
880
+ expect(zKey).toBe(moveKey);
881
+
882
+ // With that shared key, editHistory folds the two consecutive records (the
883
+ // "Move timeline clips" write + the "Reorder layers" z patch, same file,
884
+ // inside the coalesce window) into ONE undo entry spanning before→after.
885
+ const now = 1_000;
886
+ const moveEntry = buildEditHistoryEntry({
887
+ id: "m",
888
+ projectId: "p",
889
+ label: "Move timeline clips",
890
+ kind: "timeline",
891
+ coalesceKey: moveKey,
892
+ now,
893
+ files: { "index.html": { before: "<v0>", after: "<v1>" } },
894
+ });
895
+ const zEntry = buildEditHistoryEntry({
896
+ id: "z",
897
+ projectId: "p",
898
+ label: "Reorder layers",
899
+ kind: "timeline",
900
+ coalesceKey: zKey,
901
+ now: now + 50,
902
+ files: { "index.html": { before: "<v1>", after: "<v2>" } },
903
+ });
904
+ const state = pushEditHistoryEntry(
905
+ pushEditHistoryEntry(createEmptyEditHistory(), moveEntry),
906
+ zEntry,
907
+ );
908
+ expect(state.undo).toHaveLength(1);
909
+ expect(state.undo[0].files["index.html"]).toMatchObject({
910
+ before: "<v0>",
911
+ after: "<v2>",
912
+ });
913
+ });
914
+
915
+ it("distinct gestures get distinct keys (independent moves never cross-merge)", async () => {
916
+ const { onMoveElements: first } = commitLaneChange(overlapping());
917
+ const { onMoveElements: second } = commitLaneChange(overlapping());
918
+ await flushMicrotasks();
919
+ expect(first.mock.calls[0][1]).not.toBe(second.mock.calls[0][1]);
920
+ });
921
+
922
+ it("a plain move that issues no z patch persists once and records no second entry (unchanged)", async () => {
923
+ // a & b do NOT overlap in time → the z-sync produces zero patches.
924
+ const elements = [el("a", 1, 0, 5), el("b", 0, 10, 5)];
925
+ const onMoveElements = vi.fn();
926
+ const onStackingPatches = vi.fn();
927
+ commitDraggedClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), {
928
+ elements,
929
+ trackOrder: [0, 1],
930
+ updateElement: vi.fn(),
931
+ onMoveElement: vi.fn(),
932
+ onMoveElements,
933
+ readZIndex: () => 0,
934
+ onStackingPatches,
935
+ });
936
+ await flushMicrotasks();
937
+ // Move persists exactly once; no z entry is created → nothing to merge, so
938
+ // the single "Move timeline clips" entry stands alone as before.
939
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
940
+ expect(onStackingPatches).not.toHaveBeenCalled();
941
+ });
942
+ });
943
+ });