@hyperframes/studio 0.7.57 → 0.7.59

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 (247) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +27 -7
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +18029 -11563
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles/tailwind-preset.d.ts +5 -0
  14. package/dist/styles/tailwind-preset.js +8 -1
  15. package/dist/styles/tailwind-preset.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/App.tsx +6 -6
  18. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  19. package/src/components/StudioFeedbackBar.tsx +2 -2
  20. package/src/components/StudioRightPanel.tsx +29 -51
  21. package/src/components/editor/AnimationCard.test.tsx +134 -0
  22. package/src/components/editor/AnimationCard.tsx +19 -4
  23. package/src/components/editor/ArcPathControls.tsx +1 -0
  24. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  25. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  26. package/src/components/editor/DomEditOverlay.tsx +5 -2
  27. package/src/components/editor/GestureRecordControl.tsx +7 -1
  28. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  29. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  30. package/src/components/editor/LayersPanel.tsx +110 -99
  31. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  32. package/src/components/editor/PropertyPanel.tsx +144 -148
  33. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  34. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  35. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  36. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  37. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  38. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  39. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  40. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  41. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  42. package/src/components/editor/domEditingDom.ts +5 -6
  43. package/src/components/editor/domEditingElement.ts +15 -32
  44. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  45. package/src/components/editor/gsapLivePreview.ts +34 -0
  46. package/src/components/editor/layersPanelSort.ts +80 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  48. package/src/components/editor/manualEditingAvailability.ts +9 -0
  49. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  50. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  51. package/src/components/editor/propertyPanelColor.tsx +31 -1
  52. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  53. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  54. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  55. package/src/components/editor/propertyPanelFill.tsx +22 -5
  56. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  57. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  58. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  59. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  60. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  61. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  62. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  63. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  64. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  65. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  66. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  67. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  68. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  69. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  70. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  71. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  72. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  73. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  74. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  75. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  76. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  77. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  78. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  79. package/src/components/editor/propertyPanelFont.tsx +136 -95
  80. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  81. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  82. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  83. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  84. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  85. package/src/components/editor/propertyPanelSections.tsx +154 -90
  86. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  87. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  88. package/src/components/editor/propertyPanelTypes.ts +14 -1
  89. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  90. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  91. package/src/components/editor/useColorGradingController.test.ts +418 -0
  92. package/src/components/editor/useColorGradingController.ts +596 -0
  93. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  94. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  95. package/src/components/nle/PreviewOverlays.tsx +27 -2
  96. package/src/components/nle/TimelinePane.test.ts +3 -0
  97. package/src/components/nle/TimelinePane.tsx +11 -1
  98. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  99. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  100. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  101. package/src/components/nle/zLaneGesture.test.ts +176 -0
  102. package/src/components/nle/zLaneGesture.ts +77 -0
  103. package/src/components/panels/VariablesPanel.tsx +4 -0
  104. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  105. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  106. package/src/components/storyboard/FramePoster.tsx +24 -4
  107. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  108. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  109. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  110. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  111. package/src/components/storyboard/StoryboardView.tsx +9 -2
  112. package/src/components/storyboard/frameComments.test.ts +95 -0
  113. package/src/components/storyboard/frameComments.ts +115 -0
  114. package/src/components/storyboard/useFrameComments.ts +82 -0
  115. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  116. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  117. package/src/contexts/DomEditContext.tsx +4 -0
  118. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  119. package/src/contexts/VariablePromoteContext.tsx +10 -5
  120. package/src/hooks/domEditCommitRunner.ts +11 -0
  121. package/src/hooks/domEditCommitTypes.ts +17 -1
  122. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  123. package/src/hooks/fetchStubTestUtils.ts +16 -0
  124. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  125. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  126. package/src/hooks/timelineEditingHelpers.ts +32 -15
  127. package/src/hooks/timelineMoveAdapter.ts +2 -1
  128. package/src/hooks/timelineTimingSync.test.ts +703 -33
  129. package/src/hooks/timelineTimingSync.ts +282 -97
  130. package/src/hooks/timelineTrackVisibility.test.ts +55 -1
  131. package/src/hooks/timelineTrackVisibility.ts +31 -13
  132. package/src/hooks/useAppHotkeys.ts +9 -0
  133. package/src/hooks/useClipboard.ts +19 -17
  134. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  135. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  136. package/src/hooks/useDomEditCommits.ts +81 -162
  137. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  138. package/src/hooks/useDomEditSession.test.tsx +3 -1
  139. package/src/hooks/useDomEditSession.ts +18 -6
  140. package/src/hooks/useDomEditTextCommits.ts +14 -9
  141. package/src/hooks/useDomEditWiring.ts +10 -9
  142. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  143. package/src/hooks/useElementLifecycleOps.ts +182 -84
  144. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  145. package/src/hooks/useFileManager.ts +82 -60
  146. package/src/hooks/useGsapAnimationOps.ts +7 -6
  147. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  148. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  149. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  150. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  151. package/src/hooks/useGsapScriptCommits.ts +150 -50
  152. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  153. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  154. package/src/hooks/useInspectorSplitResize.ts +51 -0
  155. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  156. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  157. package/src/hooks/usePersistentEditHistory.ts +71 -31
  158. package/src/hooks/usePreviewPersistence.ts +2 -2
  159. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  160. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  161. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  162. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  163. package/src/hooks/useSdkSession.ts +155 -14
  164. package/src/hooks/useSlideshowPersist.ts +7 -1
  165. package/src/hooks/useStoryboard.ts +23 -4
  166. package/src/hooks/useTimelineEditing.test.tsx +306 -155
  167. package/src/hooks/useTimelineEditing.ts +111 -101
  168. package/src/hooks/useTimelineEditingTypes.ts +4 -1
  169. package/src/hooks/useTimelineGroupEditing.ts +51 -5
  170. package/src/hooks/useVariablesPersist.ts +9 -7
  171. package/src/icons/SystemIcons.tsx +2 -0
  172. package/src/player/components/Timeline.test.ts +31 -21
  173. package/src/player/components/Timeline.tsx +62 -91
  174. package/src/player/components/TimelineCanvas.tsx +36 -5
  175. package/src/player/components/TimelineLanes.tsx +32 -10
  176. package/src/player/components/TimelineOverlays.tsx +36 -0
  177. package/src/player/components/TimelineRuler.tsx +18 -5
  178. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  179. package/src/player/components/timelineCallbacks.ts +4 -1
  180. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  181. package/src/player/components/timelineClipDragCommit.ts +173 -71
  182. package/src/player/components/timelineGapCommit.test.ts +163 -0
  183. package/src/player/components/timelineGapCommit.ts +103 -0
  184. package/src/player/components/timelineGaps.test.ts +221 -0
  185. package/src/player/components/timelineGaps.ts +182 -0
  186. package/src/player/components/timelineLayout.test.ts +2 -1
  187. package/src/player/components/timelineLayout.ts +22 -6
  188. package/src/player/components/timelineMarquee.test.ts +20 -10
  189. package/src/player/components/timelineMarquee.ts +9 -2
  190. package/src/player/components/timelineStackingSync.test.ts +42 -1
  191. package/src/player/components/timelineStackingSync.ts +31 -16
  192. package/src/player/components/timelineZMirror.test.ts +667 -0
  193. package/src/player/components/timelineZMirror.ts +393 -0
  194. package/src/player/components/timelineZoom.test.ts +2 -2
  195. package/src/player/components/timelineZoom.ts +7 -3
  196. package/src/player/components/useTimelineClipDrag.ts +1 -1
  197. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  198. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  199. package/src/player/components/useTimelinePlayhead.ts +4 -3
  200. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  201. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  202. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  203. package/src/player/components/useTimelineStackingSync.ts +5 -3
  204. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  205. package/src/player/components/useTrackGapMenu.ts +155 -0
  206. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  207. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  208. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  209. package/src/player/lib/layerOrdering.test.ts +1 -15
  210. package/src/player/lib/layerOrdering.ts +5 -28
  211. package/src/player/lib/timelineDOM.test.ts +72 -0
  212. package/src/player/lib/timelineDOM.ts +3 -0
  213. package/src/player/lib/timelineElementHelpers.ts +35 -11
  214. package/src/player/store/playerStore.ts +12 -1
  215. package/src/styles/studio.css +22 -0
  216. package/src/styles/tailwind-preset.shared.js +7 -0
  217. package/src/telemetry/events.test.ts +10 -0
  218. package/src/telemetry/events.ts +1 -0
  219. package/src/utils/designInputTracking.test.ts +97 -0
  220. package/src/utils/designInputTracking.ts +80 -0
  221. package/src/utils/domEditSaveQueue.ts +4 -3
  222. package/src/utils/editHistory.test.ts +82 -0
  223. package/src/utils/editHistory.ts +14 -2
  224. package/src/utils/gsapSoftReload.test.ts +54 -115
  225. package/src/utils/gsapSoftReload.ts +42 -152
  226. package/src/utils/gsapUndoRestore.test.ts +269 -0
  227. package/src/utils/gsapUndoRestore.ts +269 -0
  228. package/src/utils/sdkCutover.gate.test.ts +7 -7
  229. package/src/utils/sdkCutover.test.ts +490 -57
  230. package/src/utils/sdkCutover.ts +181 -219
  231. package/src/utils/sdkEditTransaction.ts +292 -0
  232. package/src/utils/sdkResolverAttempts.ts +94 -0
  233. package/src/utils/sdkResolverShadow.test.ts +148 -0
  234. package/src/utils/sdkResolverShadow.ts +139 -106
  235. package/src/utils/setSlideshowManifest.test.ts +0 -9
  236. package/src/utils/setSlideshowManifest.ts +20 -27
  237. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  238. package/src/utils/studioFileHistory.test.ts +41 -0
  239. package/src/utils/studioFileHistory.ts +30 -27
  240. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  241. package/src/utils/studioHelpers.test.ts +45 -0
  242. package/src/utils/studioHelpers.ts +45 -14
  243. package/src/utils/studioSaveDiagnostics.ts +17 -0
  244. package/dist/assets/index-DfmYkU44.js +0 -423
  245. package/dist/assets/index-_pqzyxB1.css +0 -1
  246. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  247. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -1,5 +1,8 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
  import type { DraggedClipState } from "./useTimelineClipDrag";
3
+ // Type-only: erased at runtime, so the timelineZMirror → timelineClipDragCommit
4
+ // value-import edge stays acyclic.
5
+ import type { ZMirrorLaneMove } from "./timelineZMirror";
3
6
  import { classifyZone, normalizeToZones } from "./timelineZones";
4
7
  import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
5
8
  import { getTimelineEditCapabilities } from "./timelineEditing";
@@ -8,6 +11,7 @@ import {
8
11
  beginTimelineOptimisticGesture,
9
12
  isLatestTimelineOptimisticGesture,
10
13
  } from "./timelineOptimisticRevision";
14
+ import { runLaneZGesture } from "../../components/nle/zLaneGesture";
11
15
 
12
16
  type StartTrack = Pick<TimelineElement, "start" | "track">;
13
17
  export interface TimelineMoveEdit {
@@ -31,11 +35,14 @@ export interface DragCommitDeps {
31
35
  /** Atomic multi-clip persist (single undo) for lane changes + track inserts.
32
36
  * `coalesceKey`, when supplied, tags the resulting "Move timeline clips"
33
37
  * history entry so it merges with the lane change's z-reorder entry (see the
34
- * lane-change branch below). */
38
+ * lane-change branch below). `coalesceMs` widens that entry's fold window
39
+ * (per-gesture-unique keys make an unbounded window safe) — required when a
40
+ * server round-trip sits between the gesture's two records. */
35
41
  onMoveElements?: (
36
42
  edits: TimelineMoveEdit[],
37
43
  coalesceKey?: string,
38
44
  operation?: TimelineMoveOperation,
45
+ coalesceMs?: number,
39
46
  ) => Promise<void> | void;
40
47
  /**
41
48
  * The current multi-selection (store.selectedElementIds). When the dragged
@@ -60,18 +67,13 @@ export interface DragCommitDeps {
60
67
  * the canvas z-order commit uses (handleDomZIndexReorderCommit). Documented in
61
68
  * research/STAGE3-NEEDED-WIRING.md.
62
69
  */
63
- onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => void;
70
+ onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
64
71
  }
65
72
 
66
73
  const keyOf = (e: TimelineElement) => e.key ?? e.id;
67
74
  const round3 = (v: number) => Math.round(v * 1000) / 1000;
68
75
 
69
- // One coalesce key per lane-change gesture, shared by the move-persist history
70
- // entry ("Move timeline clips") and the follow-up z-reorder entry ("Reorder
71
- // layers") so editHistory (pushEditHistoryEntry) folds the two consecutive
72
- // records into a single undo step. A monotonic counter — NOT Date.now() /
73
- // Math.random(), which the determinism rules forbid — suffices: the key only has
74
- // to be unique per gesture and identical across the gesture's two records.
76
+ // One deterministic coalesce key shared by both records in a lane-change gesture.
75
77
  let laneChangeGestureSeq = 0;
76
78
 
77
79
  /** Whether Studio may write timing to this clip (false for locked/implicit rows). */
@@ -101,12 +103,17 @@ function canMoveElement(element: TimelineElement): boolean {
101
103
  * `false` so the caller also skips the z-sync (no orphaned z patch).
102
104
  *
103
105
  * The DOM is updated synchronously up front; the returned promise never rejects.
106
+ *
107
+ * Exported for reuse by non-drag batch time-moves (track gap closing — see
108
+ * timelineGapCommit.ts) so they share the same optimistic-apply, rollback, and
109
+ * atomic single-undo persist semantics as a drag commit.
104
110
  */
105
- function persistMoveEdits(
111
+ export function persistMoveEdits(
106
112
  edits: TimelineMoveEdit[],
107
113
  deps: DragCommitDeps,
108
114
  coalesceKey?: string,
109
115
  operation: TimelineMoveOperation = "timing",
116
+ coalesceMs?: number,
110
117
  ): Promise<boolean> {
111
118
  if (edits.length === 0) return Promise.resolve(true);
112
119
  const { updateElement, onMoveElement, onMoveElements } = deps;
@@ -148,7 +155,7 @@ function persistMoveEdits(
148
155
  : { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
149
156
  );
150
157
  const persisted = onMoveElements
151
- ? onMoveElements(persistEdits, coalesceKey, operation)
158
+ ? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
152
159
  : Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
153
160
  return Promise.resolve(persisted).then(
154
161
  () => true,
@@ -173,7 +180,7 @@ function persistMoveEdits(
173
180
  /** Same-source-file predicate: authored track numbers only compare within ONE
174
181
  * file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
175
182
  * file, not the host timeline's). `undefined` means the active composition. */
176
- const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
183
+ export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
177
184
  (a.sourceFile ?? null) === (b.sourceFile ?? null);
178
185
 
179
186
  /**
@@ -203,7 +210,7 @@ const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
203
210
  * Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
204
211
  * never here.
205
212
  */
206
- function authoredTrackForLane(
213
+ export function authoredTrackForLane(
207
214
  lane: number,
208
215
  elements: TimelineElement[],
209
216
  dragged: TimelineElement,
@@ -217,7 +224,10 @@ function authoredTrackForLane(
217
224
  if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
218
225
  }
219
226
  if (!nearest) return lane;
220
- return (nearest.authoredTrack ?? nearest.track) + (lane - nearest.track);
227
+ // Rounded: expanded children live on FRACTIONAL synthetic display rows (see
228
+ // buildChildElements), so a lane distance measured against one can carry a
229
+ // fraction — an authored data-track-index must stay an integer.
230
+ return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
221
231
  }
222
232
 
223
233
  function insertTrackValue(trackOrder: number[], insertRow: number): number {
@@ -351,8 +361,13 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
351
361
  return e;
352
362
  });
353
363
  const multiKeys = multi ? multi.keys : null;
354
- void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder").then((moved) => {
355
- if (moved && isVertical) {
364
+ if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
365
+ void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder");
366
+ return;
367
+ }
368
+ void runLaneZGesture({
369
+ commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
370
+ commitZ: () =>
356
371
  syncStackingForEdit(
357
372
  candidate,
358
373
  dragKey,
@@ -361,81 +376,123 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
361
376
  multiKeys,
362
377
  deps,
363
378
  coalesceKey,
364
- );
365
- }
366
- });
379
+ ),
380
+ }).catch(() => undefined);
367
381
  }
368
382
 
369
- /**
370
- * Insert a new track at the drop's gap boundary. The dragged clip lands on the
371
- * fractional insert lane; normalizeToZones then compacts every lane to a contiguous
372
- * integer, which shifts the clips at/below the insert down by one. That +1
373
- * renumber is the ONLY sanctioned multi-clip write; it is index-only (never z).
374
- * The whole affected set is persisted atomically (single undo), and the deliberate
375
- * vertical move syncs the dragged clip's stacking afterwards.
376
- */
383
+ /** Build the one sanctioned multi-clip write: atomically insert and compact a
384
+ * source-file zone, then let the caller sync the deliberate vertical stacking. */
377
385
  // fallow-ignore-next-line complexity
378
- function commitTrackInsert(
379
- drag: DraggedClipState,
380
- deps: DragCommitDeps,
386
+ function buildTrackInsertEdits(
387
+ element: TimelineElement,
388
+ previewStart: number,
389
+ insertRow: number,
381
390
  multi: {
382
391
  keys: ReadonlySet<string>;
383
392
  movedStart: (e: TimelineElement) => number;
384
393
  } | null,
385
- ): void {
394
+ deps: DragCommitDeps,
395
+ ): { candidate: TimelineElement[]; edits: TimelineMoveEdit[] } | null {
386
396
  const { elements, trackOrder } = deps;
387
- const dragKey = keyOf(drag.element);
388
- const targetTrack = insertTrackValue(trackOrder, drag.insertRow!);
389
- // Drop-intent set: dragged clip at the fractional insert lane (so it sorts
390
- // between its neighbours), selection members time-shifted, others as-is.
397
+ const editKey = keyOf(element);
398
+ // Expanded-child rows are synthetic host lanes, not source-file topology.
399
+ if (element.expandedParentStart != null) return null;
400
+ const targetTrack = insertTrackValue(trackOrder, insertRow);
391
401
  const candidate = elements.map((e) => {
392
- if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: targetTrack };
402
+ if (keyOf(e) === editKey) return { ...e, start: previewStart, track: targetTrack };
393
403
  if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
394
404
  return e;
395
405
  });
396
- // normalizeToZones compacts the fractional lane to a contiguous integer, which
397
- // shifts the at/below clips down by one — the sanctioned +1 index renumber.
398
- const normalized = normalizeToZones(candidate);
406
+ // Foreign display rows and the opposite zone must not affect this topology.
407
+ const writableZone = classifyZone(element);
408
+ const writable = (src: TimelineElement): boolean =>
409
+ sameSourceFile(src, element) &&
410
+ classifyZone(src) === writableZone &&
411
+ src.expandedParentStart == null;
412
+ const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
413
+ (a, b) => a - b,
414
+ );
415
+ const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
416
+ const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
417
+ const normalized = normalizeToZones(
418
+ elements.filter(writable).map((e) => {
419
+ if (keyOf(e) === editKey) {
420
+ return { ...e, start: previewStart, track: topologyTargetTrack };
421
+ }
422
+ if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
423
+ return e;
424
+ }),
425
+ );
399
426
  const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
400
- // The renumber is only correct as a WHOLE-SET write: skipping an unwritable
401
- // clip whose lane shifts leaves its track colliding with a renumbered
402
- // neighbour, and the next normalize merges the two lanes. If any shifted clip
403
- // can't be written, refuse the insert instead of persisting a broken layout.
427
+ // A partial zone renumber creates collisions; refuse a shifted locked row.
404
428
  for (const norm of normalized) {
405
429
  const src = bySrc.get(keyOf(norm));
406
- if (src && !canMoveElement(src) && norm.track !== src.track) {
430
+ if (
431
+ src &&
432
+ writable(src) &&
433
+ !canMoveElement(src) &&
434
+ norm.track !== (src.authoredTrack ?? src.track)
435
+ ) {
407
436
  console.warn(
408
437
  `[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
409
438
  );
410
- return;
439
+ return null;
411
440
  }
412
441
  }
413
442
  const edits: TimelineMoveEdit[] = [];
443
+ if (multi) {
444
+ for (const src of elements) {
445
+ const srcKey = keyOf(src);
446
+ if (srcKey !== editKey && multi.keys.has(srcKey) && !writable(src) && canMoveElement(src)) {
447
+ edits.push({
448
+ element: src,
449
+ updates: { start: multi.movedStart(src), track: src.track },
450
+ persistTrack: src.authoredTrack,
451
+ });
452
+ }
453
+ }
454
+ }
414
455
  for (const norm of normalized) {
415
456
  const src = bySrc.get(keyOf(norm));
416
- if (!src) continue;
417
- // Capabilities gate (unchanged-lane clips only reach here now): never write
418
- // a locked/implicit clip.
419
- if (!canMoveElement(src)) continue;
457
+ if (!src || !canMoveElement(src)) continue;
420
458
  const start =
421
- keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm))
422
- ? (multi?.movedStart(src) ?? drag.previewStart)
459
+ keyOf(norm) === editKey || multi?.keys.has(keyOf(norm))
460
+ ? (multi?.movedStart(src) ?? previewStart)
423
461
  : src.start;
424
462
  edits.push({ element: src, updates: { start, track: norm.track } });
425
463
  }
464
+ return { candidate, edits };
465
+ }
466
+
467
+ function commitTrackInsert(
468
+ drag: DraggedClipState,
469
+ deps: DragCommitDeps,
470
+ multi: {
471
+ keys: ReadonlySet<string>;
472
+ movedStart: (e: TimelineElement) => number;
473
+ } | null,
474
+ ): void {
475
+ const dragKey = keyOf(drag.element);
476
+ const built = buildTrackInsertEdits(
477
+ drag.element,
478
+ drag.previewStart,
479
+ drag.insertRow!,
480
+ multi,
481
+ deps,
482
+ );
483
+ if (!built) return;
484
+ const { candidate, edits } = built;
485
+ if (edits.length === 0) return;
426
486
 
427
487
  const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
428
- void persistMoveEdits(edits, deps, coalesceKey, "track-insert").then((moved) => {
429
- // Skip the z-sync when the insert produced NO move edits (e.g. every clip in
430
- // the set is locked/implicit and gets filtered out). persistMoveEdits resolves
431
- // `true` for an empty batch so the caller's serialization proceeds, but firing
432
- // the z-sync here would record an orphaned z-only history entry for a move that
433
- // never persisted.
434
- if (moved && edits.length > 0) {
435
- // Reason the z-sync on the drop-intent `candidate` (dragged clip at its
436
- // fractional insert lane) — NOT the re-normalized lanes — so the sync sees
437
- // the user's move. The guard lane is the aimed insert row (a boundary in
438
- // display-lane space, comparable to the clip's contiguous current lane).
488
+ if (!deps.readZIndex || !deps.onStackingPatches) {
489
+ void persistMoveEdits(edits, deps, coalesceKey, "track-insert");
490
+ return;
491
+ }
492
+ void runLaneZGesture({
493
+ commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
494
+ commitZ: () =>
495
+ // Sync from the fractional drop intent, not the normalized persisted lanes.
439
496
  syncStackingForEdit(
440
497
  candidate,
441
498
  dragKey,
@@ -444,9 +501,54 @@ function commitTrackInsert(
444
501
  multi ? multi.keys : null,
445
502
  deps,
446
503
  coalesceKey,
447
- );
448
- }
449
- });
504
+ ),
505
+ }).catch(() => undefined);
506
+ }
507
+
508
+ /**
509
+ * Commit the timeline lane move that MIRRORS a canvas z-order menu action
510
+ * (resolveZMirrorLaneMove's non-null result). Same machinery as a lane drag:
511
+ *
512
+ * - kind "move": persistMoveEdits with `{start: element.start, track: displayTrack}`
513
+ * + `persistTrack` — identical shape to commitDraggedClipMove's lane-change
514
+ * branch (optimistic store update, authoredTrack mirror, rollback on failure).
515
+ * - kind "insert": buildTrackInsertEdits — the SAME renumber core commitTrackInsert
516
+ * uses — then the same atomic persist.
517
+ *
518
+ * Deliberately NO syncStackingForEdit here: the z values were just set by the
519
+ * user's menu action, and the lane→z sync would recompute (and fight) them. The
520
+ * mirror caller also omits `readZIndex`/`onStackingPatches` from `deps`, so even
521
+ * a future call into the sync would no-op (double protection; see
522
+ * useCanvasZOrderTimelineMirror).
523
+ *
524
+ * `coalesceKey` MUST be the z persist's key (`z-reorder:<action>:<ids>:g<seq>`)
525
+ * so editHistory folds the z write and this track write into ONE undo entry, and
526
+ * `coalesceMs` MUST widen this record's fold window: the mirror only runs after
527
+ * the z persist's server round-trip resolved, so under real network latency the
528
+ * gap between the two records exceeds the reducer's 300ms default and the fold
529
+ * would never happen live. The key is unique per gesture, so an unbounded
530
+ * window can never merge distinct gestures.
531
+ *
532
+ * Resolves `true` once the move persisted, `false` on rollback / refused insert.
533
+ */
534
+ export function commitZMirrorLaneMove(
535
+ element: TimelineElement,
536
+ move: NonNullable<ZMirrorLaneMove>,
537
+ deps: DragCommitDeps,
538
+ coalesceKey: string,
539
+ coalesceMs?: number,
540
+ ): Promise<boolean> {
541
+ if (move.kind === "move") {
542
+ const edit: TimelineMoveEdit = {
543
+ element,
544
+ updates: { start: element.start, track: move.displayTrack },
545
+ persistTrack: move.persistTrack,
546
+ };
547
+ return persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs);
548
+ }
549
+ const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
550
+ if (!built || built.edits.length === 0) return Promise.resolve(false);
551
+ return persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs);
450
552
  }
451
553
 
452
554
  /**
@@ -467,18 +569,16 @@ function syncStackingForEdit(
467
569
  multiKeys: ReadonlySet<string> | null,
468
570
  deps: DragCommitDeps,
469
571
  coalesceKey?: string,
470
- ): void {
572
+ ): Promise<void> {
471
573
  const { readZIndex, onStackingPatches } = deps;
472
- if (!readZIndex || !onStackingPatches) return;
574
+ if (!readZIndex || !onStackingPatches) return Promise.resolve();
473
575
 
474
576
  // Aiming at the clip's OWN current display lane is not a relocation — never
475
577
  // touch z (guards the pure-time-move invariant even if a spurious topology call
476
578
  // slips through). Every real lane-realization drop aims at a DIFFERENT lane.
477
- if (aimedLane === currentLane) return;
579
+ if (aimedLane === currentLane) return Promise.resolve();
478
580
 
479
- // `candidate` is in discovery order, so its array index IS the DOM document
480
- // position. Equal-z clips paint by DOM order, so the sync needs it to decide
481
- // "is A above B" (see StackingElement.domIndex).
581
+ // Discovery order is DOM order, which breaks equal-z ties.
482
582
  const stackingEls = candidate.map((el, domIndex) => ({
483
583
  key: keyOf(el),
484
584
  start: el.start,
@@ -486,6 +586,7 @@ function syncStackingForEdit(
486
586
  track: el.track,
487
587
  zIndex: readZIndex(el),
488
588
  isAudio: classifyZone(el) === "audio",
589
+ sourceFile: el.sourceFile,
489
590
  domIndex,
490
591
  stackingContextId: el.stackingContextId ?? null,
491
592
  }));
@@ -494,5 +595,6 @@ function syncStackingForEdit(
494
595
  if (multiKeys) for (const k of multiKeys) if (k !== dragKey) editedKeys.push(k);
495
596
 
496
597
  const patches = computeStackingPatches(stackingEls, editedKeys);
497
- if (patches.length > 0) onStackingPatches(patches, coalesceKey);
598
+ if (patches.length === 0) return Promise.resolve();
599
+ return Promise.resolve(onStackingPatches(patches, coalesceKey)).then(() => undefined);
498
600
  }
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import type { DragCommitDeps } from "./timelineClipDragCommit";
4
+ import {
5
+ canShiftTrackGapClips,
6
+ commitCloseAllTrackGaps,
7
+ commitCloseTrackGap,
8
+ } from "./timelineGapCommit";
9
+ import { resolveAllTrackGaps } from "./timelineGaps";
10
+
11
+ function el(id: string, start: number, duration: number, track = 0): TimelineElement {
12
+ // domId + video tag → getTimelineEditCapabilities(...).canMove === true
13
+ return { id, tag: "video", start, duration, track, domId: id };
14
+ }
15
+
16
+ function lockedEl(id: string, start: number, duration: number): TimelineElement {
17
+ return { ...el(id, start, duration), timelineLocked: true };
18
+ }
19
+
20
+ function makeDeps(laneElements: TimelineElement[]) {
21
+ const onMoveElements = vi.fn(() => Promise.resolve());
22
+ const updateElement = vi.fn();
23
+ const deps: DragCommitDeps = {
24
+ elements: laneElements,
25
+ trackOrder: [0],
26
+ updateElement,
27
+ onMoveElements,
28
+ };
29
+ return { deps, onMoveElements, updateElement };
30
+ }
31
+
32
+ /** Assert exactly ONE atomic persist batch; return its flattened edits + coalesce key. */
33
+ function singleBatch(onMoveElements: ReturnType<typeof vi.fn>) {
34
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
35
+ const [edits, coalesceKey] = onMoveElements.mock.calls[0] as unknown as [
36
+ Array<{ element: TimelineElement; updates: { start: number; track: number } }>,
37
+ string,
38
+ ];
39
+ return { coalesceKey, edits: edits.map((e) => ({ id: e.element.id, ...e.updates })) };
40
+ }
41
+
42
+ describe("commitCloseTrackGap", () => {
43
+ it("persists ONE atomic batch shifting the next clip and every clip after it", () => {
44
+ const lane = [el("a", 0, 2), el("b", 5, 3), el("c", 10, 1)];
45
+ const { deps, onMoveElements } = makeDeps(lane);
46
+
47
+ expect(commitCloseTrackGap(lane, 3, deps)).toBe(true);
48
+
49
+ const { edits, coalesceKey } = singleBatch(onMoveElements);
50
+ // Gap is [2, 5) → width 3; b and c shift left by 3, tracks unchanged.
51
+ expect(edits).toEqual([
52
+ { id: "b", start: 2, track: 0 },
53
+ { id: "c", start: 7, track: 0 },
54
+ ]);
55
+ expect(typeof coalesceKey).toBe("string");
56
+ expect(coalesceKey).toMatch(/^track-gap-close:\d+$/);
57
+ });
58
+
59
+ it("optimistically applies the same starts to the store", () => {
60
+ const lane = [el("a", 0, 2), el("b", 5, 3)];
61
+ const { deps, updateElement } = makeDeps(lane);
62
+ commitCloseTrackGap(lane, 3, deps);
63
+ expect(updateElement).toHaveBeenCalledWith("b", { start: 2, track: 0 });
64
+ });
65
+
66
+ it("closes the leading gap (first clip lands at 0)", () => {
67
+ const lane = [el("a", 2, 3), el("b", 6, 1)];
68
+ const { deps, onMoveElements } = makeDeps(lane);
69
+
70
+ expect(commitCloseTrackGap(lane, 1, deps)).toBe(true);
71
+ const [edits] = onMoveElements.mock.calls[0] as unknown as [
72
+ Array<{ element: TimelineElement; updates: { start: number; track: number } }>,
73
+ ];
74
+ expect(edits.map((e) => ({ id: e.element.id, start: e.updates.start }))).toEqual([
75
+ { id: "a", start: 0 },
76
+ { id: "b", start: 4 },
77
+ ]);
78
+ });
79
+
80
+ it("uses a fresh coalesce key per gesture", () => {
81
+ const lane = [el("a", 0, 2), el("b", 5, 3)];
82
+ const first = makeDeps(lane);
83
+ const second = makeDeps(lane);
84
+ commitCloseTrackGap(lane, 3, first.deps);
85
+ commitCloseTrackGap(lane, 3, second.deps);
86
+ const keyA = first.onMoveElements.mock.calls[0][1 as never];
87
+ const keyB = second.onMoveElements.mock.calls[0][1 as never];
88
+ expect(keyA).not.toEqual(keyB);
89
+ });
90
+
91
+ it("refuses (no write) when there is no clip right of the point", () => {
92
+ const lane = [el("a", 0, 2)];
93
+ const { deps, onMoveElements, updateElement } = makeDeps(lane);
94
+ expect(commitCloseTrackGap(lane, 5, deps)).toBe(false);
95
+ expect(onMoveElements).not.toHaveBeenCalled();
96
+ expect(updateElement).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it("refuses (no partial compaction) when ANY shifting clip is unmovable", () => {
100
+ const lane = [el("a", 0, 2), el("b", 5, 3), lockedEl("c", 10, 1)];
101
+ const { deps, onMoveElements, updateElement } = makeDeps(lane);
102
+ expect(commitCloseTrackGap(lane, 3, deps)).toBe(false);
103
+ expect(onMoveElements).not.toHaveBeenCalled();
104
+ expect(updateElement).not.toHaveBeenCalled();
105
+ });
106
+ });
107
+
108
+ describe("commitCloseAllTrackGaps", () => {
109
+ it("compacts the whole lane in ONE atomic batch (leading gap included)", () => {
110
+ const lane = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
111
+ const { deps, onMoveElements } = makeDeps(lane);
112
+
113
+ expect(commitCloseAllTrackGaps(lane, deps)).toBe(true);
114
+
115
+ const { edits, coalesceKey } = singleBatch(onMoveElements);
116
+ expect(edits).toEqual([
117
+ { id: "a", start: 0, track: 0 },
118
+ { id: "b", start: 2, track: 0 },
119
+ { id: "c", start: 5, track: 0 },
120
+ ]);
121
+ expect(coalesceKey).toMatch(/^track-gap-close:\d+$/);
122
+ });
123
+
124
+ it("refuses when the track is already contiguous (no gaps)", () => {
125
+ const lane = [el("a", 0, 2), el("b", 2, 3)];
126
+ const { deps, onMoveElements } = makeDeps(lane);
127
+ expect(commitCloseAllTrackGaps(lane, deps)).toBe(false);
128
+ expect(onMoveElements).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("refuses when any shifting clip is unmovable, even if others could move", () => {
132
+ const lane = [lockedEl("a", 1, 2), el("b", 5, 3)];
133
+ const { deps, onMoveElements, updateElement } = makeDeps(lane);
134
+ expect(commitCloseAllTrackGaps(lane, deps)).toBe(false);
135
+ expect(onMoveElements).not.toHaveBeenCalled();
136
+ expect(updateElement).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it("proceeds when an unmovable clip does NOT need to shift", () => {
140
+ // Locked clip already sits flush at 0 — only movable clips shift.
141
+ const lane = [lockedEl("a", 0, 2), el("b", 4, 1)];
142
+ const { deps, onMoveElements } = makeDeps(lane);
143
+ expect(commitCloseAllTrackGaps(lane, deps)).toBe(true);
144
+ const [edits] = onMoveElements.mock.calls[0] as unknown as [
145
+ Array<{ element: TimelineElement; updates: { start: number } }>,
146
+ ];
147
+ expect(edits.map((e) => ({ id: e.element.id, start: e.updates.start }))).toEqual([
148
+ { id: "b", start: 2 },
149
+ ]);
150
+ });
151
+ });
152
+
153
+ describe("canShiftTrackGapClips", () => {
154
+ it("is true only when every named clip is movable", () => {
155
+ const lane = [el("a", 1, 2), lockedEl("b", 5, 3)];
156
+ expect(canShiftTrackGapClips(lane, [{ key: "a", newStart: 0 }])).toBe(true);
157
+ expect(canShiftTrackGapClips(lane, resolveAllTrackGaps(lane))).toBe(false);
158
+ });
159
+
160
+ it("is false for unknown keys", () => {
161
+ expect(canShiftTrackGapClips([el("a", 0, 1)], [{ key: "ghost", newStart: 0 }])).toBe(false);
162
+ });
163
+ });
@@ -0,0 +1,103 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import { getTimelineEditCapabilities } from "./timelineEditing";
3
+ import {
4
+ persistMoveEdits,
5
+ type DragCommitDeps,
6
+ type TimelineMoveEdit,
7
+ } from "./timelineClipDragCommit";
8
+ import {
9
+ laneGapFloor,
10
+ resolveAllTrackGaps,
11
+ resolveCloseGapShifts,
12
+ resolveTrackGapAt,
13
+ type TrackGapShift,
14
+ } from "./timelineGaps";
15
+
16
+ /**
17
+ * Commit layer for the track-gap context menu ("Close gap" / "Close all gaps").
18
+ *
19
+ * Each action is ONE atomic {@link persistMoveEdits} batch — pure time moves
20
+ * (`updates.track === element.track`, no authored-track rewrite) tagged with a
21
+ * per-gesture-unique coalesce key, so an action is exactly one undo entry and
22
+ * flows through the existing move pipeline (optimistic store apply + rollback,
23
+ * SDK fast path, patchIframeDomTiming preview).
24
+ *
25
+ * Refusal rule: if ANY clip that must shift is unmovable
26
+ * ({@link getTimelineEditCapabilities}.canMove === false), the whole action is
27
+ * refused — never a partial compaction. The menu disables the item via
28
+ * {@link canShiftTrackGapClips}; the commit re-checks as defense in depth.
29
+ */
30
+
31
+ const keyOf = (e: TimelineElement) => e.key ?? e.id;
32
+
33
+ // Per-gesture-unique coalesce key. A monotonic counter — NOT Date.now() /
34
+ // Math.random() (determinism rules) — mirrors laneChangeGestureSeq in
35
+ // timelineClipDragCommit.ts.
36
+ let gapCloseGestureSeq = 0;
37
+
38
+ /** True when every clip named in `shifts` may be time-moved. */
39
+ export function canShiftTrackGapClips(
40
+ laneElements: readonly TimelineElement[],
41
+ shifts: readonly TrackGapShift[],
42
+ ): boolean {
43
+ const byKey = new Map(laneElements.map((e) => [keyOf(e), e]));
44
+ return shifts.every((s) => {
45
+ const element = byKey.get(s.key);
46
+ return element != null && getTimelineEditCapabilities(element).canMove;
47
+ });
48
+ }
49
+
50
+ function buildShiftEdits(
51
+ laneElements: readonly TimelineElement[],
52
+ shifts: readonly TrackGapShift[],
53
+ ): TimelineMoveEdit[] | null {
54
+ if (shifts.length === 0 || !canShiftTrackGapClips(laneElements, shifts)) return null;
55
+ const byKey = new Map(laneElements.map((e) => [keyOf(e), e]));
56
+ return shifts.map((s) => {
57
+ const element = byKey.get(s.key)!;
58
+ return { element, updates: { start: s.newStart, track: element.track } };
59
+ });
60
+ }
61
+
62
+ function commitShifts(
63
+ laneElements: readonly TimelineElement[],
64
+ shifts: readonly TrackGapShift[],
65
+ deps: DragCommitDeps,
66
+ ): boolean {
67
+ const edits = buildShiftEdits(laneElements, shifts);
68
+ if (!edits) return false;
69
+ void persistMoveEdits(edits, deps, `track-gap-close:${gapCloseGestureSeq++}`);
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * Close the ONE gap under `time` on the lane: the next clip and every clip
75
+ * after it on that lane shift left by the gap's width. Returns false (and
76
+ * writes nothing) when there is no gap at the point or a shifting clip is
77
+ * unmovable.
78
+ */
79
+ export function commitCloseTrackGap(
80
+ laneElements: readonly TimelineElement[],
81
+ time: number,
82
+ deps: DragCommitDeps,
83
+ ): boolean {
84
+ const gap = resolveTrackGapAt(laneElements, time, undefined, laneGapFloor(laneElements));
85
+ if (!gap) return false;
86
+ return commitShifts(laneElements, resolveCloseGapShifts(laneElements, gap), deps);
87
+ }
88
+
89
+ /**
90
+ * Compact the whole lane (leading gap included): clips become contiguous from
91
+ * 0, order and durations preserved. Returns false (and writes nothing) when
92
+ * the lane has no gaps or a shifting clip is unmovable.
93
+ */
94
+ export function commitCloseAllTrackGaps(
95
+ laneElements: readonly TimelineElement[],
96
+ deps: DragCommitDeps,
97
+ ): boolean {
98
+ return commitShifts(
99
+ laneElements,
100
+ resolveAllTrackGaps(laneElements, undefined, laneGapFloor(laneElements)),
101
+ deps,
102
+ );
103
+ }