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