@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,6 +1,6 @@
1
1
  import { memo } from "react";
2
2
  import type { TimelineTheme } from "./timelineTheme";
3
- import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
3
+ import { GUTTER, RULER_H, TRACKS_LEFT_PAD, formatTimelineTickLabel } from "./timelineLayout";
4
4
  import { usePlayerStore } from "../store/playerStore";
5
5
  import { secondsToFrame } from "../lib/time";
6
6
  import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
@@ -45,7 +45,7 @@ export const TimelineRuler = memo(function TimelineRuler({
45
45
  the ruler's own small ticks mark intervals (no full-height lines). */}
46
46
  <svg
47
47
  className="absolute pointer-events-none"
48
- style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
48
+ style={{ left: GUTTER + TRACKS_LEFT_PAD, width: trackContentWidth, zIndex: 0 }}
49
49
  height={totalH}
50
50
  >
51
51
  {showBeats &&
@@ -74,17 +74,30 @@ export const TimelineRuler = memo(function TimelineRuler({
74
74
  rows and drag overlays but below the playhead (z 100). */}
75
75
  <div
76
76
  className="sticky top-0 flex"
77
- style={{ height: RULER_H, width: GUTTER + trackContentWidth, zIndex: 70 }}
77
+ style={{
78
+ height: RULER_H,
79
+ width: GUTTER + TRACKS_LEFT_PAD + trackContentWidth,
80
+ zIndex: 70,
81
+ }}
78
82
  >
79
83
  <div
80
84
  className="sticky left-0 z-[12] flex-shrink-0"
81
85
  style={{
82
86
  width: GUTTER,
83
- // Ruler corner uses the panel surface — same as the ruler strip itself.
87
+ // Ruler corner uses the panel surface — same as the ruler strip
88
+ // itself, and NO right border: the ruler band stays completely
89
+ // clean until 00:00 (the header-boundary line belongs to the track
90
+ // rows below, not the ruler).
84
91
  background: theme.shellBackground,
85
- borderRight: `1px solid ${theme.gutterBorder}`,
86
92
  }}
87
93
  />
94
+ {/* Left breathing pad — scrolls with the content, so 00:00 starts a
95
+ beat right of the gutter (see TRACKS_LEFT_PAD). */}
96
+ <div
97
+ aria-hidden="true"
98
+ className="flex-shrink-0"
99
+ style={{ width: TRACKS_LEFT_PAD, background: theme.shellBackground }}
100
+ />
88
101
  <div
89
102
  className="relative overflow-hidden"
90
103
  style={{
@@ -0,0 +1,116 @@
1
+ import { memo } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
+
5
+ interface TrackGapContextMenuProps {
6
+ x: number;
7
+ y: number;
8
+ /** Width (seconds) of the gap under the pointer, or null when no clip exists to the right. */
9
+ gapWidth: number | null;
10
+ /** "Close gap" actionable: a gap exists AND every clip that must shift is movable. */
11
+ canCloseGap: boolean;
12
+ /** "Close all gaps" actionable: the lane has gaps AND every shifting clip is movable. */
13
+ canCloseAllGaps: boolean;
14
+ /** The lane has at least one gap (distinguishes the two disabled reasons). */
15
+ hasAnyGaps: boolean;
16
+ onClose: () => void;
17
+ onCloseGap: () => void;
18
+ onCloseAllGaps: () => void;
19
+ /** Hover state for the gap-strip highlight overlay (null = nothing hovered).
20
+ * Only reported for ACTIONABLE rows — a disabled row closes nothing, so
21
+ * highlighting from it would promise an action that can't happen. */
22
+ onHoverAction: (action: "close-gap" | "close-all" | null) => void;
23
+ }
24
+
25
+ /**
26
+ * Context menu for right-clicking EMPTY space on a timeline lane
27
+ * (CapCut/Premiere-style). Offers "Close gap" (collapse the clicked gap by
28
+ * shifting the following clips on that lane left) and "Close all gaps"
29
+ * (compact the whole lane contiguous from 0). Both rows are ALWAYS present —
30
+ * an inapplicable action dims with a tooltip explaining why, rather than
31
+ * vanishing into a one-item menu. Hovering an actionable row highlights the
32
+ * gap strip(s) it would close (via onHoverAction → TimelineCanvas overlay).
33
+ * Styling mirrors ClipContextMenu.
34
+ */
35
+ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
36
+ x,
37
+ y,
38
+ gapWidth,
39
+ canCloseGap,
40
+ canCloseAllGaps,
41
+ hasAnyGaps,
42
+ onClose,
43
+ onCloseGap,
44
+ onCloseAllGaps,
45
+ onHoverAction,
46
+ }: TrackGapContextMenuProps) {
47
+ const menuRef = useContextMenuDismiss(onClose);
48
+
49
+ const menuWidth = 200;
50
+ const menuHeight = 68;
51
+ const overflowY = y + menuHeight - window.innerHeight;
52
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
53
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
54
+
55
+ const itemClass = (enabled: boolean) =>
56
+ `w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
57
+ enabled
58
+ ? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
59
+ : "text-neutral-600 cursor-not-allowed"
60
+ }`;
61
+
62
+ // Disabled reasons: no gap under the pointer beats the lock reason — a
63
+ // pointer not on a gap has nothing to close regardless of movability.
64
+ const closeGapTitle = canCloseGap
65
+ ? undefined
66
+ : gapWidth == null
67
+ ? "No gap here"
68
+ : "A clip on this track can't be moved";
69
+ const closeAllTitle = canCloseAllGaps
70
+ ? undefined
71
+ : hasAnyGaps
72
+ ? "A clip on this track can't be moved"
73
+ : "No gaps on this track";
74
+
75
+ return createPortal(
76
+ <div
77
+ ref={menuRef}
78
+ className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
79
+ style={{ left: adjustedX, top: adjustedY }}
80
+ onPointerLeave={() => onHoverAction(null)}
81
+ >
82
+ <button
83
+ type="button"
84
+ className={itemClass(canCloseGap)}
85
+ disabled={!canCloseGap}
86
+ title={closeGapTitle}
87
+ onPointerEnter={() => onHoverAction(canCloseGap ? "close-gap" : null)}
88
+ onClick={() => {
89
+ if (!canCloseGap) return;
90
+ onCloseGap();
91
+ onClose();
92
+ }}
93
+ >
94
+ <span>Close gap</span>
95
+ {gapWidth != null && (
96
+ <span className="text-neutral-500 text-[10px] ml-3">{gapWidth.toFixed(2)}s</span>
97
+ )}
98
+ </button>
99
+ <button
100
+ type="button"
101
+ className={itemClass(canCloseAllGaps)}
102
+ disabled={!canCloseAllGaps}
103
+ title={closeAllTitle}
104
+ onPointerEnter={() => onHoverAction(canCloseAllGaps ? "close-all" : null)}
105
+ onClick={() => {
106
+ if (!canCloseAllGaps) return;
107
+ onCloseAllGaps();
108
+ onClose();
109
+ }}
110
+ >
111
+ <span>Close all gaps</span>
112
+ </button>
113
+ </div>,
114
+ document.body,
115
+ );
116
+ });
@@ -31,11 +31,14 @@ export interface TimelineEditCallbacks {
31
31
  ) => Promise<void> | void;
32
32
  /** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
33
33
  * `coalesceKey` (drag-commit gesture id) merges the move history entry with a
34
- * lane change's follow-up z-reorder entry into one undo step. */
34
+ * lane change's follow-up z-reorder entry into one undo step; `coalesceMs`
35
+ * widens that entry's fold window when a server round-trip separates the
36
+ * gesture's records (per-gesture-unique keys keep the fold gesture-scoped). */
35
37
  onMoveElements?: (
36
38
  edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
37
39
  coalesceKey?: string,
38
40
  operation?: TimelineMoveOperation,
41
+ coalesceMs?: number,
39
42
  ) => Promise<void> | void;
40
43
  onResizeElement?: (
41
44
  element: TimelineElement,
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
3
3
  import type { DraggedClipState } from "./useTimelineClipDrag";
4
4
  import {
5
5
  commitDraggedClipMove,
6
+ commitZMirrorLaneMove,
6
7
  type DragCommitDeps,
7
8
  type TimelineMoveEdit,
8
9
  } from "./timelineClipDragCommit";
@@ -12,6 +13,7 @@ import {
12
13
  pushEditHistoryEntry,
13
14
  } from "../../utils/editHistory";
14
15
  import { normalizeToZones } from "./timelineZones";
16
+ import { resolveZMirrorLaneMove } from "./timelineZMirror";
15
17
  import type { StackingPatch } from "./timelineStackingSync";
16
18
 
17
19
  function el(
@@ -29,9 +31,7 @@ function el(
29
31
  /** Flush the microtask chain: the z-sync now fires only after the move persist
30
32
  * promise resolves (serialized), so tests asserting on it must await. */
31
33
  async function flushMicrotasks(): Promise<void> {
32
- await Promise.resolve();
33
- await Promise.resolve();
34
- await Promise.resolve();
34
+ for (let step = 0; step < 8; step += 1) await Promise.resolve();
35
35
  }
36
36
 
37
37
  function drag(
@@ -370,6 +370,73 @@ describe("commitDraggedClipMove", () => {
370
370
  expect(map.b.track).toBe(2); // at/below the insert → +1 shift
371
371
  });
372
372
 
373
+ it("a selected audio passenger moves in time during a visual insert without being renumbered", () => {
374
+ const a = { ...el("a", 0, 0, 5), sourceFile: "scene.html" };
375
+ const b = { ...el("b", 1, 10, 5), sourceFile: "scene.html" };
376
+ const t = { ...el("t", 1, 0, 5), sourceFile: "scene.html" };
377
+ const audio = {
378
+ ...el("audio", 2, 4, 20, "audio"),
379
+ sourceFile: "scene.html",
380
+ authoredTrack: 7,
381
+ };
382
+
383
+ const { onMoveElements } = runClipMove(
384
+ drag(t, { previewStart: 5, previewTrack: 1, insertRow: 1 }),
385
+ {
386
+ elements: [a, b, t, audio],
387
+ trackOrder: [0, 1, 2],
388
+ selectedKeys: new Set(["t", "audio"]),
389
+ },
390
+ );
391
+
392
+ const map = editMap(onMoveElements.mock.calls[0][0]);
393
+ expect(map.audio).toEqual({ start: 9, track: 7 });
394
+ });
395
+
396
+ it("an audio insert persists zone-local tracks without rewriting visual lanes", () => {
397
+ const v0 = el("v0", 0, 0, 5);
398
+ const v1 = el("v1", 1, 0, 5);
399
+ const a = { ...el("a", 2, 0, 5, "audio"), authoredTrack: 0 };
400
+ const b = { ...el("b", 3, 10, 5, "audio"), authoredTrack: 1 };
401
+ const t = { ...el("t", 3, 0, 5, "audio"), authoredTrack: 1 };
402
+
403
+ const { onMoveElements } = runClipMove(
404
+ drag(t, { previewStart: 0, previewTrack: 3, insertRow: 3 }),
405
+ { elements: [v0, v1, a, b, t], trackOrder: [0, 1, 2, 3] },
406
+ );
407
+
408
+ expect(editMap(onMoveElements.mock.calls[0][0])).toEqual({
409
+ a: { start: 0, track: 0 },
410
+ t: { start: 0, track: 1 },
411
+ b: { start: 10, track: 2 },
412
+ });
413
+ });
414
+
415
+ it("refuses an audio insert when it would renumber a locked authored row", () => {
416
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
417
+ const visual = el("visual", 0, 0, 5);
418
+ const locked = {
419
+ ...el("locked", 1, 0, 5, "audio"),
420
+ authoredTrack: 0,
421
+ timelineLocked: true,
422
+ };
423
+ const target = { ...el("target", 2, 0, 5, "audio"), authoredTrack: 1 };
424
+
425
+ try {
426
+ const { onMoveElements } = runClipMove(
427
+ drag(target, { previewStart: 0, previewTrack: 2, insertRow: 1 }),
428
+ { elements: [visual, locked, target], trackOrder: [0, 1, 2] },
429
+ );
430
+
431
+ expect(onMoveElements).not.toHaveBeenCalled();
432
+ expect(warnSpy).toHaveBeenCalledWith(
433
+ expect.stringContaining("locked clip locked would need renumbering"),
434
+ );
435
+ } finally {
436
+ warnSpy.mockRestore();
437
+ }
438
+ });
439
+
373
440
  describe("lane ↔ stacking sync", () => {
374
441
  it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", async () => {
375
442
  // a & b overlap in time. Elements carry their authored z (as real discovery
@@ -1024,3 +1091,212 @@ describe("commitDraggedClipMove", () => {
1024
1091
  });
1025
1092
  });
1026
1093
  });
1094
+
1095
+ describe("commitZMirrorLaneMove", () => {
1096
+ const mirrorDeps = (elements: TimelineElement[], trackOrder: number[]) => {
1097
+ const updateElement = vi.fn();
1098
+ const onMoveElements = vi.fn();
1099
+ return {
1100
+ updateElement,
1101
+ onMoveElements,
1102
+ deps: { elements, trackOrder, updateElement, onMoveElements } as DragCommitDeps,
1103
+ };
1104
+ };
1105
+
1106
+ it("kind:move persists start + display lane with the persistTrack override (same shape as a lane drag)", async () => {
1107
+ // t sits at lane 2 in a sparse file (authored 7); the mirror lands it on
1108
+ // lane 0 whose authored track is 3.
1109
+ const t = { ...el("t", 2, 0, 10), authoredTrack: 7 };
1110
+ const elements = [{ ...el("a", 0, 20, 5), authoredTrack: 3 }, el("b", 1, 0, 10), t];
1111
+ const { updateElement, onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2]);
1112
+ const moved = await commitZMirrorLaneMove(
1113
+ t,
1114
+ { kind: "move", displayTrack: 0, persistTrack: 3 },
1115
+ deps,
1116
+ "z-reorder:bring-forward:t",
1117
+ );
1118
+ expect(moved).toBe(true);
1119
+ // Optimistic store update: DISPLAY lane + the written authoredTrack mirror.
1120
+ expect(updateElement).toHaveBeenCalledWith("t", {
1121
+ start: 0,
1122
+ track: 0,
1123
+ authoredTrack: 3,
1124
+ });
1125
+ // Persist: authored-space track, the z persist's coalesce key, lane-reorder op.
1126
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
1127
+ const [persistEdits, coalesceKey, operation] = onMoveElements.mock.calls[0];
1128
+ expect(editMap(persistEdits)).toEqual({ t: { start: 0, track: 3 } });
1129
+ expect(coalesceKey).toBe("z-reorder:bring-forward:t");
1130
+ expect(operation).toBe("lane-reorder");
1131
+ });
1132
+
1133
+ it("kind:insert reuses the track-insert renumber core (+1 shift below the new lane)", async () => {
1134
+ // a,b,c mutually overlapping on lanes 0/1/2. Mirror-insert c at row 1: c
1135
+ // lands on the new lane, b (at/below) shifts down — identical to the drag
1136
+ // insert test above, proving the shared core (no duplicated renumber logic).
1137
+ const elements = [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)];
1138
+ const { onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2]);
1139
+ const moved = await commitZMirrorLaneMove(
1140
+ elements[2],
1141
+ { kind: "insert", insertRow: 1 },
1142
+ deps,
1143
+ "z-reorder:bring-forward:c",
1144
+ );
1145
+ expect(moved).toBe(true);
1146
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
1147
+ expect(onMoveElements.mock.calls[0][1]).toBe("z-reorder:bring-forward:c");
1148
+ expect(onMoveElements.mock.calls[0][2]).toBe("track-insert");
1149
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1150
+ expect(map.a.track).toBe(0);
1151
+ expect(map.c.track).toBe(1);
1152
+ expect(map.b.track).toBe(2);
1153
+ });
1154
+
1155
+ it("visual mirror inserts never persist or renumber same-file audio", async () => {
1156
+ // b and t share a visual lane because they do not overlap. Inserting t between
1157
+ // a and b creates one extra visual lane, so whole-timeline normalization moves
1158
+ // audio from display lane 2 to 3. That display-only shift must not be written.
1159
+ const a = { ...el("a", 0, 0, 5), sourceFile: "scene.html" };
1160
+ const b = { ...el("b", 1, 10, 5), sourceFile: "scene.html" };
1161
+ const t = { ...el("t", 1, 0, 5), sourceFile: "scene.html" };
1162
+ const audio = { ...el("audio", 2, 0, 20, "audio"), sourceFile: "scene.html" };
1163
+ const { onMoveElements, deps } = mirrorDeps([a, b, t, audio], [0, 1, 2]);
1164
+
1165
+ const moved = await commitZMirrorLaneMove(
1166
+ t,
1167
+ { kind: "insert", insertRow: 1 },
1168
+ deps,
1169
+ "z-reorder:bring-forward:t",
1170
+ );
1171
+
1172
+ expect(moved).toBe(true);
1173
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1174
+ expect(map.audio).toBeUndefined();
1175
+ expect(map).toEqual({
1176
+ a: { start: 0, track: 0 },
1177
+ b: { start: 10, track: 2 },
1178
+ t: { start: 0, track: 1 },
1179
+ });
1180
+ });
1181
+
1182
+ it("foreign expanded rows never distort the root-file insert topology", async () => {
1183
+ const host = { ...el("host", 0, 0, 5), sourceFile: "index.html" };
1184
+ const child1 = {
1185
+ ...el("child-1", 0.25, 0, 5),
1186
+ sourceFile: "scene.html",
1187
+ expandedParentStart: 0,
1188
+ };
1189
+ const child2 = {
1190
+ ...el("child-2", 0.5, 0, 5),
1191
+ sourceFile: "scene.html",
1192
+ expandedParentStart: 0,
1193
+ };
1194
+ const b = { ...el("b", 1, 0, 5), sourceFile: "index.html" };
1195
+ const t = { ...el("t", 2, 0, 5), sourceFile: "index.html" };
1196
+ const elements = [host, child1, child2, b, t];
1197
+ const { onMoveElements, deps } = mirrorDeps(elements, [0, 0.25, 0.5, 1, 2]);
1198
+
1199
+ const moved = await commitZMirrorLaneMove(
1200
+ t,
1201
+ { kind: "insert", insertRow: 1 },
1202
+ deps,
1203
+ "z-reorder:bring-forward:t",
1204
+ );
1205
+
1206
+ expect(moved).toBe(true);
1207
+ expect(editMap(onMoveElements.mock.calls[0][0])).toEqual({
1208
+ host: { start: 0, track: 0 },
1209
+ t: { start: 0, track: 1 },
1210
+ b: { start: 0, track: 2 },
1211
+ });
1212
+ });
1213
+
1214
+ it("never triggers the lane→z stacking sync (it would fight the just-set z values)", async () => {
1215
+ // Even with BOTH z-sync deps supplied (the drag paths would engage them for
1216
+ // a vertical move like this), the mirror commit must not emit stacking
1217
+ // patches — the z values were just written by the user's menu action.
1218
+ const t = el("t", 2, 0, 10);
1219
+ const elements = [el("a", 0, 20, 5), el("b", 1, 0, 10), t];
1220
+ const { deps } = mirrorDeps(elements, [0, 1, 2]);
1221
+ const onStackingPatches = vi.fn();
1222
+ const moved = await commitZMirrorLaneMove(
1223
+ t,
1224
+ { kind: "move", displayTrack: 0, persistTrack: 0 },
1225
+ { ...deps, readZIndex: () => 0, onStackingPatches },
1226
+ "z-reorder:bring-forward:t",
1227
+ );
1228
+ await flushMicrotasks();
1229
+ expect(moved).toBe(true);
1230
+ expect(onStackingPatches).not.toHaveBeenCalled();
1231
+ });
1232
+
1233
+ it("resolves false and rolls the store back when the persist rejects", async () => {
1234
+ const t = el("t", 1, 0, 10);
1235
+ const elements = [el("a", 0, 20, 5), t];
1236
+ const updateElement = vi.fn();
1237
+ const onMoveElements = vi.fn().mockRejectedValue(new Error("boom"));
1238
+ const moved = await commitZMirrorLaneMove(
1239
+ t,
1240
+ { kind: "move", displayTrack: 0, persistTrack: 0 },
1241
+ { elements, trackOrder: [0, 1], updateElement, onMoveElements },
1242
+ "z-reorder:send-backward:t",
1243
+ );
1244
+ expect(moved).toBe(false);
1245
+ // Optimistic write then rollback to the original lane.
1246
+ expect(updateElement).toHaveBeenLastCalledWith("t", {
1247
+ start: 0,
1248
+ track: 1,
1249
+ authoredTrack: undefined,
1250
+ });
1251
+ });
1252
+
1253
+ it("END-TO-END one-element step: resolver insertRow renumbers the clip strictly between the two neighbors", async () => {
1254
+ // 3 stacked back-to-back clips + a free lane beyond the far one. Send t
1255
+ // (top) backward past b: the resolver must bound at c and produce the
1256
+ // insert row IMMEDIATELY below b, and commitZMirrorLaneMove's renumber must
1257
+ // land t strictly between b and c — never on the farther free lane 3.
1258
+ const t = el("t", 0, 0, 10);
1259
+ const b = el("b", 1, 0, 10);
1260
+ const c = el("c", 2, 0, 10);
1261
+ const far = el("far", 3, 20, 5); // free over t's span, beyond c
1262
+ const elements = [t, b, c, far];
1263
+ const move = resolveZMirrorLaneMove({
1264
+ action: "send-backward",
1265
+ element: t,
1266
+ elements,
1267
+ crossedKey: "b",
1268
+ });
1269
+ expect(move).toEqual({ kind: "insert", insertRow: 2 });
1270
+ const { onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2, 3]);
1271
+ const moved = await commitZMirrorLaneMove(t, move!, deps, "z-reorder:send-backward:t");
1272
+ expect(moved).toBe(true);
1273
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1274
+ // The renumber compacts t's vacated top lane, so the whole set shifts up by
1275
+ // one while t lands on the b/c boundary — strictly between the two.
1276
+ expect(map).toEqual({
1277
+ b: { start: 0, track: 0 },
1278
+ t: { start: 0, track: 1 },
1279
+ c: { start: 0, track: 2 },
1280
+ far: { start: 20, track: 3 },
1281
+ });
1282
+ expect(map.b.track).toBeLessThan(map.t.track);
1283
+ expect(map.t.track).toBeLessThan(map.c.track);
1284
+ });
1285
+
1286
+ it("resolves false for a refused insert (locked clip would need renumbering)", async () => {
1287
+ // b is locked and sits at/below the insert row, so the whole-set renumber
1288
+ // is refused — no persist call.
1289
+ const a = el("a", 0, 0, 5);
1290
+ const b: TimelineElement = { ...el("b", 1, 0, 5), timelineLocked: true };
1291
+ const c = el("c", 2, 0, 5);
1292
+ const { onMoveElements, deps } = mirrorDeps([a, b, c], [0, 1, 2]);
1293
+ const moved = await commitZMirrorLaneMove(
1294
+ c,
1295
+ { kind: "insert", insertRow: 1 },
1296
+ deps,
1297
+ "z-reorder:bring-forward:c",
1298
+ );
1299
+ expect(moved).toBe(false);
1300
+ expect(onMoveElements).not.toHaveBeenCalled();
1301
+ });
1302
+ });