@hyperframes/studio 0.7.56 → 0.7.58

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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  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 +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -6,7 +6,7 @@ import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
6
6
  import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
7
7
  import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
8
8
  import type { TimelineTheme } from "./timelineTheme";
9
- import { GUTTER, TRACK_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
9
+ import { GUTTER, TRACK_H, TRACKS_LEFT_PAD, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
10
10
  import {
11
11
  usePlayerStore,
12
12
  type TimelineElement,
@@ -80,6 +80,12 @@ export interface TimelineLaneBaseProps {
80
80
  toClipPercentage: number,
81
81
  ) => void;
82
82
  onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
83
+ /**
84
+ * Right-click on EMPTY lane space (not on a clip — those preventDefault
85
+ * before this fires — not the gutter/ruler, not below the lanes). `time` is
86
+ * the timeline time (seconds) under the pointer on that lane.
87
+ */
88
+ onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
83
89
  beatAnalysis?: MusicBeatAnalysis | null;
84
90
  }
85
91
 
@@ -133,6 +139,7 @@ export function TimelineLanes({
133
139
  onContextMenuKeyframe,
134
140
  onMoveKeyframe,
135
141
  onContextMenuClip,
142
+ onContextMenuLane,
136
143
  beatAnalysis,
137
144
  onToggleTrackHidden,
138
145
  onResizeElement,
@@ -167,21 +174,14 @@ export function TimelineLanes({
167
174
  const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
168
175
  const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
169
176
  return (
170
- <div
171
- key={trackNum}
172
- className="relative flex"
173
- style={{
174
- height: TRACK_H,
175
- background: rowBackground,
176
- borderBottom: `1px solid ${theme.rowBorder}`,
177
- }}
178
- >
177
+ <div key={trackNum} className="relative flex" style={{ height: TRACK_H }}>
179
178
  <div
180
179
  className="sticky left-0 z-[12] flex-shrink-0 flex flex-col items-center justify-center gap-0.5"
181
180
  style={{
182
181
  width: GUTTER,
183
182
  background: theme.gutterBackground,
184
183
  borderRight: `1px solid ${theme.gutterBorder}`,
184
+ borderBottom: `1px solid ${theme.rowBorder}`,
185
185
  }}
186
186
  >
187
187
  {isAudioTrack && (
@@ -211,13 +211,35 @@ export function TimelineLanes({
211
211
  )}
212
212
  </button>
213
213
  </div>
214
+ {/* Left breathing pad — empty lane surface before t=0, scrolling
215
+ with the content (the horizontal TRACKS_TOP_PAD). Sits OUTSIDE
216
+ the time-mapped content div so clip/beat/menu math stays
217
+ content-relative (clip left = t·pps). */}
218
+ <div
219
+ aria-hidden="true"
220
+ className="flex-shrink-0"
221
+ style={{ width: TRACKS_LEFT_PAD }}
222
+ />
214
223
  <div
215
224
  style={{
216
225
  width: trackContentWidth,
226
+ background: rowBackground,
227
+ borderBottom: `1px solid ${theme.rowBorder}`,
217
228
  opacity: isTrackHidden ? 0.35 : 1,
218
229
  transition: "opacity 120ms ease",
219
230
  }}
220
231
  className="relative"
232
+ onContextMenu={(e: React.MouseEvent) => {
233
+ // Clip / keyframe-diamond context menus preventDefault at the
234
+ // target before this bubble handler runs — respect them so a
235
+ // right-click on a clip never also opens the gap menu.
236
+ if (e.defaultPrevented || !onContextMenuLane) return;
237
+ const rect = e.currentTarget.getBoundingClientRect();
238
+ const time = (e.clientX - rect.left) / pps;
239
+ if (time < 0) return;
240
+ e.preventDefault();
241
+ onContextMenuLane(e, trackNum, time);
242
+ }}
221
243
  >
222
244
  {/* Faint beat lines in every track's background (behind the clips);
223
245
  the active move-snap target is highlighted. */}
@@ -8,6 +8,7 @@ import {
8
8
  type KeyframeDiamondContextMenuState,
9
9
  } from "./KeyframeDiamondContextMenu";
10
10
  import { ClipContextMenu } from "./ClipContextMenu";
11
+ import { TrackGapContextMenu } from "./TrackGapContextMenu";
11
12
  import { TimelineShortcutHint } from "./TimelineShortcutHint";
12
13
 
13
14
  interface ClipContextMenuState {
@@ -16,6 +17,16 @@ interface ClipContextMenuState {
16
17
  element: TimelineElement;
17
18
  }
18
19
 
20
+ /** Resolved model for the empty-lane-space (track gap) context menu. */
21
+ interface TrackGapContextMenuState {
22
+ x: number;
23
+ y: number;
24
+ gapWidth: number | null;
25
+ canCloseGap: boolean;
26
+ canCloseAllGaps: boolean;
27
+ hasAnyGaps: boolean;
28
+ }
29
+
19
30
  interface TimelineOverlaysProps {
20
31
  theme: TimelineTheme;
21
32
  showShortcutHint: boolean;
@@ -36,6 +47,11 @@ interface TimelineOverlaysProps {
36
47
  onSplitElement: TimelineEditCallbacks["onSplitElement"];
37
48
  pinZoomBeforeEdit: () => void;
38
49
  onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
50
+ gapContextMenu: TrackGapContextMenuState | null;
51
+ onDismissGapContextMenu: () => void;
52
+ onCloseTrackGap: () => void;
53
+ onCloseAllTrackGaps: () => void;
54
+ onHoverGapAction: (action: "close-gap" | "close-all" | null) => void;
39
55
  }
40
56
 
41
57
  // The timeline's floating overlays, rendered as siblings above the scroll area:
@@ -61,6 +77,11 @@ export function TimelineOverlays({
61
77
  onSplitElement,
62
78
  pinZoomBeforeEdit,
63
79
  onDeleteElement,
80
+ gapContextMenu,
81
+ onDismissGapContextMenu,
82
+ onCloseTrackGap,
83
+ onCloseAllTrackGaps,
84
+ onHoverGapAction,
64
85
  }: TimelineOverlaysProps) {
65
86
  return (
66
87
  <>
@@ -117,6 +138,21 @@ export function TimelineOverlays({
117
138
  }}
118
139
  />
119
140
  )}
141
+
142
+ {gapContextMenu && (
143
+ <TrackGapContextMenu
144
+ x={gapContextMenu.x}
145
+ y={gapContextMenu.y}
146
+ gapWidth={gapContextMenu.gapWidth}
147
+ canCloseGap={gapContextMenu.canCloseGap}
148
+ canCloseAllGaps={gapContextMenu.canCloseAllGaps}
149
+ hasAnyGaps={gapContextMenu.hasAnyGaps}
150
+ onClose={onDismissGapContextMenu}
151
+ onCloseGap={onCloseTrackGap}
152
+ onCloseAllGaps={onCloseAllTrackGaps}
153
+ onHoverAction={onHoverGapAction}
154
+ />
155
+ )}
120
156
  </>
121
157
  );
122
158
  }
@@ -1,6 +1,8 @@
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
+ import { usePlayerStore } from "../store/playerStore";
5
+ import { secondsToFrame } from "../lib/time";
4
6
  import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
5
7
 
6
8
  interface TimelineRulerProps {
@@ -26,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
26
28
  theme,
27
29
  beatAnalysis,
28
30
  }: TimelineRulerProps) {
31
+ const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
29
32
  const beatTimes = beatAnalysis?.beatTimes ?? [];
30
33
  const beatStrengths = beatAnalysis?.beatStrengths ?? [];
31
34
 
@@ -38,27 +41,13 @@ export const TimelineRuler = memo(function TimelineRuler({
38
41
 
39
42
  return (
40
43
  <>
41
- {/* Grid lines (major ticks + beat lines) behind the tracks (background).
42
- Opaque track rows hide them; only the beat dots show on tracks. */}
44
+ {/* Background SVG beat lines only; major-tick gridlines removed so only
45
+ the ruler's own small ticks mark intervals (no full-height lines). */}
43
46
  <svg
44
47
  className="absolute pointer-events-none"
45
- style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
48
+ style={{ left: GUTTER + TRACKS_LEFT_PAD, width: trackContentWidth, zIndex: 0 }}
46
49
  height={totalH}
47
50
  >
48
- {major.map((t) => {
49
- const x = t * pps;
50
- return (
51
- <line
52
- key={`g-${t}`}
53
- x1={x}
54
- y1={RULER_H}
55
- x2={x}
56
- y2={totalH}
57
- stroke={theme.tickMinor}
58
- strokeWidth="1"
59
- />
60
- );
61
- })}
62
51
  {showBeats &&
63
52
  beatTimes.map((t, i) => {
64
53
  const x = t * pps;
@@ -79,41 +68,75 @@ export const TimelineRuler = memo(function TimelineRuler({
79
68
  })}
80
69
  </svg>
81
70
 
82
- {/* Ruler. The bar fills the full panel width (canvas is min 100% wide);
83
- calc(100% - GUTTER) equals trackContentWidth when zoomed in and extends
84
- past the content when zoomed out. Ticks stay at composition coordinates. */}
71
+ {/* Ruler sticky so the timestamps stay visible while the tracks scroll
72
+ vertically. Opaque background (plus the gutter corner block) so clips
73
+ scrolling underneath don't bleed through; z-index sits above the track
74
+ rows and drag overlays but below the playhead (z 100). */}
85
75
  <div
86
- className="relative overflow-hidden"
76
+ className="sticky top-0 flex"
87
77
  style={{
88
78
  height: RULER_H,
89
- marginLeft: GUTTER,
90
- width: `calc(100% - ${GUTTER}px)`,
91
- background: theme.gutterBackground,
92
- borderBottom: `1px solid ${theme.rulerBorder}`,
79
+ width: GUTTER + TRACKS_LEFT_PAD + trackContentWidth,
80
+ zIndex: 70,
93
81
  }}
94
82
  >
95
- {minor.map((t) => (
96
- <div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
97
- <div className="w-px h-2" style={{ background: theme.tickMinor }} />
98
- </div>
99
- ))}
83
+ <div
84
+ className="sticky left-0 z-[12] flex-shrink-0"
85
+ style={{
86
+ width: GUTTER,
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).
91
+ background: theme.shellBackground,
92
+ }}
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
+ />
101
+ <div
102
+ className="relative overflow-hidden"
103
+ style={{
104
+ height: RULER_H,
105
+ width: trackContentWidth,
106
+ // Ruler background = panel surface (#0A0A0B) — no bottom border,
107
+ // no tick lines (CapCut-style clean ruler, labels only).
108
+ background: theme.shellBackground,
109
+ }}
110
+ >
111
+ {/* Each 1px tick line is shifted -0.5px so its CENTER sits exactly on
112
+ t * pps — matching the playhead line, which is also centered on
113
+ GUTTER + t * pps (see getTimelinePlayheadLeft). Without the shift
114
+ a tick spans [x, x+1) and its center is half a pixel right. */}
115
+ {minor.map((t) => (
116
+ <div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
117
+ <div className="w-px h-2" style={{ background: theme.tickMinor }} />
118
+ </div>
119
+ ))}
100
120
 
101
- {major.map((t) => (
102
- <div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
103
- <span
104
- className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
105
- style={{
106
- color: theme.tickText,
107
- left: 5,
108
- top: 5,
109
- fontSize: 10,
110
- }}
111
- >
112
- {formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
113
- </span>
114
- <div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
115
- </div>
116
- ))}
121
+ {major.map((t) => (
122
+ <div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps - 0.5 }}>
123
+ <span
124
+ className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
125
+ style={{
126
+ color: theme.tickText,
127
+ left: 5,
128
+ top: 5,
129
+ fontSize: 10,
130
+ }}
131
+ >
132
+ {timeDisplayMode === "frame"
133
+ ? secondsToFrame(t)
134
+ : formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
135
+ </span>
136
+ <div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
137
+ </div>
138
+ ))}
139
+ </div>
117
140
  </div>
118
141
  </>
119
142
  );
@@ -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,