@hyperframes/studio 0.7.57 → 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 (112) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
  2. package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
  3. package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-Bf1x1y8H.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 +15 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5954 -4474
  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 +27 -14
  17. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  18. package/src/components/editor/DomEditOverlay.tsx +5 -2
  19. package/src/components/editor/LayersPanel.tsx +110 -99
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  21. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  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/useLayerRevealOverride.test.ts +169 -0
  26. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  27. package/src/components/nle/PreviewOverlays.tsx +27 -2
  28. package/src/components/nle/TimelinePane.test.ts +3 -0
  29. package/src/components/nle/TimelinePane.tsx +11 -1
  30. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  32. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  33. package/src/components/nle/zLaneGesture.test.ts +176 -0
  34. package/src/components/nle/zLaneGesture.ts +77 -0
  35. package/src/hooks/domEditCommitTypes.ts +17 -1
  36. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  37. package/src/hooks/fetchStubTestUtils.ts +16 -0
  38. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  39. package/src/hooks/timelineEditingHelpers.ts +32 -15
  40. package/src/hooks/timelineMoveAdapter.ts +2 -1
  41. package/src/hooks/timelineTimingSync.test.ts +703 -33
  42. package/src/hooks/timelineTimingSync.ts +282 -97
  43. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  44. package/src/hooks/timelineTrackVisibility.ts +4 -3
  45. package/src/hooks/useClipboard.ts +19 -17
  46. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  47. package/src/hooks/useDomEditCommits.ts +143 -78
  48. package/src/hooks/useDomEditSession.test.tsx +3 -1
  49. package/src/hooks/useDomEditSession.ts +1 -1
  50. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  51. package/src/hooks/useElementLifecycleOps.ts +174 -81
  52. package/src/hooks/usePreviewPersistence.ts +2 -2
  53. package/src/hooks/useTimelineEditing.test.tsx +302 -154
  54. package/src/hooks/useTimelineEditing.ts +97 -93
  55. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  56. package/src/hooks/useTimelineGroupEditing.ts +35 -3
  57. package/src/player/components/Timeline.test.ts +31 -21
  58. package/src/player/components/Timeline.tsx +62 -91
  59. package/src/player/components/TimelineCanvas.tsx +36 -5
  60. package/src/player/components/TimelineLanes.tsx +32 -10
  61. package/src/player/components/TimelineOverlays.tsx +36 -0
  62. package/src/player/components/TimelineRuler.tsx +18 -5
  63. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  64. package/src/player/components/timelineCallbacks.ts +4 -1
  65. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  66. package/src/player/components/timelineClipDragCommit.ts +173 -71
  67. package/src/player/components/timelineGapCommit.test.ts +163 -0
  68. package/src/player/components/timelineGapCommit.ts +103 -0
  69. package/src/player/components/timelineGaps.test.ts +221 -0
  70. package/src/player/components/timelineGaps.ts +182 -0
  71. package/src/player/components/timelineLayout.test.ts +2 -1
  72. package/src/player/components/timelineLayout.ts +22 -6
  73. package/src/player/components/timelineMarquee.test.ts +20 -10
  74. package/src/player/components/timelineMarquee.ts +9 -2
  75. package/src/player/components/timelineStackingSync.test.ts +42 -1
  76. package/src/player/components/timelineStackingSync.ts +31 -16
  77. package/src/player/components/timelineZMirror.test.ts +667 -0
  78. package/src/player/components/timelineZMirror.ts +393 -0
  79. package/src/player/components/timelineZoom.test.ts +2 -2
  80. package/src/player/components/timelineZoom.ts +7 -3
  81. package/src/player/components/useTimelineClipDrag.ts +1 -1
  82. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  83. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  84. package/src/player/components/useTimelinePlayhead.ts +4 -3
  85. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  86. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  87. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  88. package/src/player/components/useTimelineStackingSync.ts +5 -3
  89. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  90. package/src/player/components/useTrackGapMenu.ts +155 -0
  91. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  92. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  93. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  94. package/src/player/lib/layerOrdering.test.ts +1 -15
  95. package/src/player/lib/layerOrdering.ts +5 -28
  96. package/src/player/lib/timelineDOM.test.ts +72 -0
  97. package/src/player/lib/timelineDOM.ts +3 -0
  98. package/src/player/lib/timelineElementHelpers.ts +35 -11
  99. package/src/player/store/playerStore.ts +12 -1
  100. package/src/telemetry/events.test.ts +10 -0
  101. package/src/telemetry/events.ts +1 -0
  102. package/src/utils/domEditSaveQueue.ts +4 -3
  103. package/src/utils/editHistory.test.ts +82 -0
  104. package/src/utils/editHistory.ts +14 -2
  105. package/src/utils/gsapSoftReload.test.ts +54 -115
  106. package/src/utils/gsapSoftReload.ts +42 -152
  107. package/src/utils/gsapUndoRestore.test.ts +269 -0
  108. package/src/utils/gsapUndoRestore.ts +269 -0
  109. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  110. package/dist/assets/index-_pqzyxB1.css +0 -1
  111. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  112. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -9,7 +9,7 @@ import { defaultTimelineTheme } from "./timelineTheme";
9
9
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
10
10
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
11
11
  import { useTimelineActiveClips } from "./useTimelineActiveClips";
12
- import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
12
+ import { getTrackStyle } from "./timelineIcons";
13
13
  import { useTimelineZoom } from "./useTimelineZoom";
14
14
  import { useTimelineAssetDrop } from "./timelineDragDrop";
15
15
  import { TimelineEmptyState } from "./TimelineEmptyState";
@@ -20,15 +20,14 @@ import { TimelineOverlays } from "./TimelineOverlays";
20
20
  import { useTimelineEditPinning } from "./useTimelineEditPinning";
21
21
  import { useTimelineStackingSync } from "./useTimelineStackingSync";
22
22
  import { useTimelineGeometry } from "./useTimelineGeometry";
23
- import {
24
- GUTTER,
25
- generateTicks,
26
- getTimelineCanvasHeight,
27
- shouldShowTimelineShortcutHint,
28
- } from "./timelineLayout";
23
+ import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
24
+ import { GUTTER, TRACKS_LEFT_PAD, generateTicks, getTimelineCanvasHeight } from "./timelineLayout";
25
+ import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
29
26
  import { STUDIO_PREVIEW_FPS } from "../lib/time";
30
27
  import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
31
28
  import type { TimelineProps } from "./TimelineTypes";
29
+ import { useTrackGapMenu } from "./useTrackGapMenu";
30
+ import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
32
31
 
33
32
  // Re-export pure utilities so existing imports from "./Timeline" still resolve.
34
33
  export {
@@ -114,106 +113,41 @@ export const Timeline = memo(function Timeline({
114
113
  const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
115
114
 
116
115
  useMountEffect(() => {
117
- const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
118
- const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
116
+ const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown");
119
117
  const blur = () => setShiftHeld(false);
120
- window.addEventListener("keydown", down);
121
- window.addEventListener("keyup", up);
118
+ window.addEventListener("keydown", key);
119
+ window.addEventListener("keyup", key);
122
120
  window.addEventListener("blur", blur);
123
121
  return () => {
124
- window.removeEventListener("keydown", down);
125
- window.removeEventListener("keyup", up);
122
+ window.removeEventListener("keydown", key);
123
+ window.removeEventListener("keyup", key);
126
124
  window.removeEventListener("blur", blur);
127
125
  };
128
126
  });
129
127
 
130
128
  const [showPopover, setShowPopover] = useState(false);
131
- const [showShortcutHint, setShowShortcutHint] = useState(true);
132
129
  const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
133
130
  const [clipContextMenu, setClipContextMenu] = useState<{
134
131
  x: number;
135
132
  y: number;
136
133
  element: TimelineElement;
137
134
  } | null>(null);
138
- const [viewportWidth, setViewportWidth] = useState(0);
139
- const roRef = useRef<ResizeObserver | null>(null);
140
- const shortcutHintRafRef = useRef(0);
141
-
142
- const syncShortcutHintVisibility = useCallback(() => {
143
- const scroll = scrollRef.current;
144
- setShowShortcutHint(
145
- scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
146
- );
147
- }, []);
148
-
149
- const scheduleShortcutHintVisibilitySync = useCallback(() => {
150
- if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
151
- shortcutHintRafRef.current = requestAnimationFrame(() => {
152
- shortcutHintRafRef.current = 0;
153
- syncShortcutHintVisibility();
154
- });
155
- }, [syncShortcutHintVisibility]);
156
135
 
157
136
  const setContainerRef = useCallback((el: HTMLDivElement | null) => {
158
137
  containerRef.current = el;
159
138
  }, []);
160
139
 
161
- // Last horizontal scroll offset, RESTORED across the post-edit iframe reload (which clamps into
162
- // a scroll jump); with the pinned zoom this keeps the user parked at the same spot after edits.
140
+ // Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
163
141
  const lastScrollLeftRef = useRef(0);
164
- const setScrollRef = useCallback(
165
- (el: HTMLDivElement | null) => {
166
- if (roRef.current) {
167
- roRef.current.disconnect();
168
- roRef.current = null;
169
- }
170
- scrollRef.current = el;
171
- if (!el) return;
172
-
173
- const syncScrollViewport = () => {
174
- setViewportWidth(el.clientWidth);
175
- scheduleShortcutHintVisibilitySync();
176
- };
177
-
178
- syncScrollViewport();
179
- roRef.current = new ResizeObserver(syncScrollViewport);
180
- roRef.current.observe(el);
181
- },
182
- [scheduleShortcutHintVisibilitySync],
183
- );
184
-
185
- useMountEffect(() => () => {
186
- roRef.current?.disconnect();
187
- if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
188
- });
189
142
 
190
143
  const effectiveDuration = useMemo(() => {
191
144
  const safeDur = Number.isFinite(duration) ? duration : 0;
192
145
  if (rawElements.length === 0) return safeDur;
193
- const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration));
194
- const result = Math.max(safeDur, maxEnd);
146
+ const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration));
195
147
  return Number.isFinite(result) ? result : safeDur;
196
148
  }, [rawElements, duration]);
197
149
 
198
- const tracks = useMemo(() => {
199
- const map = new Map<number, typeof expandedElements>();
200
- for (const el of expandedElements) {
201
- const list = map.get(el.track) ?? [];
202
- list.push(el);
203
- map.set(el.track, list);
204
- }
205
- return Array.from(map.entries()).sort(([a], [b]) => a - b);
206
- }, [expandedElements]);
207
-
208
- const trackStyles = useMemo(() => {
209
- const map = new Map<number, TrackVisualStyle>();
210
- for (const [trackNum, els] of tracks) {
211
- map.set(trackNum, getTrackStyle(els[0]?.tag ?? ""));
212
- }
213
- return map;
214
- }, [tracks]);
215
-
216
- const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
150
+ const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
217
151
  const trackOrderRef = useRef(trackOrder);
218
152
  trackOrderRef.current = trackOrder;
219
153
  const expandedElementsRef = useRef(expandedElements);
@@ -222,8 +156,7 @@ export const Timeline = memo(function Timeline({
222
156
  const ppsRef = useRef(100);
223
157
  const durationRef = useRef(effectiveDuration);
224
158
  durationRef.current = effectiveDuration;
225
- // Declared here (used before the fitPps derivation below) so the edit-pin
226
- // wrappers can close over it; `fitPpsRef.current` is refreshed each render.
159
+ // Declared before the fitPps derivation so the edit-pin wrappers can close over it.
227
160
  const fitPpsRef = useRef(100);
228
161
 
229
162
  const {
@@ -252,6 +185,22 @@ export const Timeline = memo(function Timeline({
252
185
  expandedElementsRef,
253
186
  });
254
187
 
188
+ const {
189
+ gapMenuModel,
190
+ gapHighlight,
191
+ setHoveredGapAction,
192
+ openGapMenu,
193
+ dismissGapMenu,
194
+ closeTrackGap,
195
+ closeAllTrackGaps,
196
+ } = useTrackGapMenu({
197
+ tracks,
198
+ expandedElementsRef,
199
+ trackOrderRef,
200
+ onMoveElement: pinnedOnMoveElement,
201
+ onMoveElements: pinnedOnMoveElements,
202
+ });
203
+
255
204
  const {
256
205
  draggedClip,
257
206
  setDraggedClip,
@@ -293,6 +242,11 @@ export const Timeline = memo(function Timeline({
293
242
  }, [draggedClip, trackOrder]);
294
243
 
295
244
  const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
245
+ const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
246
+ timelineReady,
247
+ expandedElements.length,
248
+ totalH,
249
+ ]);
296
250
  const keyframeCache = usePlayerStore((s) => s.keyframeCache);
297
251
  const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
298
252
  const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
@@ -328,6 +282,16 @@ export const Timeline = memo(function Timeline({
328
282
  lastScrollLeftRef,
329
283
  });
330
284
 
285
+ const laneGapStrips = useTimelineGapHighlights({
286
+ gapHighlight,
287
+ tracks,
288
+ selectedElementId,
289
+ selectedElementIds,
290
+ expandedElements,
291
+ dragActive: draggedClip?.started === true || resizingClip != null,
292
+ displayDuration,
293
+ });
294
+
331
295
  const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({
332
296
  playheadRef,
333
297
  scrollRef,
@@ -379,8 +343,7 @@ export const Timeline = memo(function Timeline({
379
343
  trackOrderRef,
380
344
  onSelectElement,
381
345
  });
382
- // Wire setRangeSelection into the stable ref consumed by useTimelineClipDrag
383
- setRangeSelectionRef.current = setRangeSelection;
346
+ setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
384
347
 
385
348
  const prevSelectedRef = useRef(selectedElementRef.current);
386
349
  // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
@@ -402,10 +365,6 @@ export const Timeline = memo(function Timeline({
402
365
  );
403
366
  const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
404
367
 
405
- useEffect(() => {
406
- syncShortcutHintVisibility();
407
- }, [syncShortcutHintVisibility, timelineReady, expandedElements.length, totalH]);
408
-
409
368
  const getPreviewElement = useCallback(
410
369
  (element: TimelineElement): TimelineElement => {
411
370
  if (
@@ -459,8 +418,7 @@ export const Timeline = memo(function Timeline({
459
418
  tabIndex={-1}
460
419
  className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
461
420
  onScroll={(e) => {
462
- // Remember the live offset so it can be restored across a post-edit reload.
463
- lastScrollLeftRef.current = e.currentTarget.scrollLeft;
421
+ lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
464
422
  }}
465
423
  onDragOver={handleAssetDragOver}
466
424
  onDragLeave={() => clearDropPreview()}
@@ -468,7 +426,8 @@ export const Timeline = memo(function Timeline({
468
426
  onPointerDown={(e) => {
469
427
  if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
470
428
  const rect = scrollRef.current.getBoundingClientRect();
471
- const x = e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER;
429
+ const x =
430
+ e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
472
431
  const splitTime = Math.max(0, x / pps);
473
432
  onRazorSplitAll?.(splitTime);
474
433
  return;
@@ -489,6 +448,7 @@ export const Timeline = memo(function Timeline({
489
448
  majorTickInterval={majorTickInterval}
490
449
  rangeSelection={rangeSelection}
491
450
  marqueeRect={marqueeRect}
451
+ laneGapStrips={laneGapStrips}
492
452
  theme={theme}
493
453
  displayTrackOrder={displayTrackOrder}
494
454
  trackOrder={trackOrder}
@@ -560,8 +520,14 @@ export const Timeline = memo(function Timeline({
560
520
  e.preventDefault();
561
521
  setSelectedElementId(el.key ?? el.id);
562
522
  onSelectElement?.(el);
523
+ dismissGapMenu();
563
524
  setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
564
525
  }}
526
+ onContextMenuLane={(e, track, time) => {
527
+ if (draggedClip?.started || resizingClip) return;
528
+ setClipContextMenu(null);
529
+ openGapMenu({ x: e.clientX, y: e.clientY, track, time });
530
+ }}
565
531
  />
566
532
  {activeTool === "razor" && razorGuideX !== null && (
567
533
  <div
@@ -594,6 +560,11 @@ export const Timeline = memo(function Timeline({
594
560
  onSplitElement={onSplitElement}
595
561
  pinZoomBeforeEdit={pinZoomBeforeEdit}
596
562
  onDeleteElement={_onDeleteElement}
563
+ gapContextMenu={gapMenuModel}
564
+ onDismissGapContextMenu={dismissGapMenu}
565
+ onCloseTrackGap={closeTrackGap}
566
+ onCloseAllTrackGaps={closeAllTrackGaps}
567
+ onHoverGapAction={setHoveredGapAction}
597
568
  />
598
569
  </div>
599
570
  );
@@ -10,6 +10,7 @@ import {
10
10
  CLIP_Y,
11
11
  TRACKS_TOP_PAD,
12
12
  TRACKS_BOTTOM_PAD,
13
+ TRACKS_LEFT_PAD,
13
14
  PLAYHEAD_HEAD_W,
14
15
  getTimelinePlayheadLeft,
15
16
  getTimelineRowTop,
@@ -23,6 +24,7 @@ import { TimelineClip } from "./TimelineClip";
23
24
  import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes";
24
25
  import { renderClipChildren } from "./timelineClipChildren";
25
26
  import { useTimelineRevealClip } from "./useTimelineRevealClip";
27
+ import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
26
28
 
27
29
  interface TimelineCanvasProps extends TimelineLaneBaseProps {
28
30
  major: number[];
@@ -37,6 +39,8 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps {
37
39
  /** Playhead is being actively scrubbed — fills the grab-handle head. */
38
40
  isScrubbing: boolean;
39
41
  playheadRef: React.RefObject<HTMLDivElement | null>;
42
+ /** Gap strips: loud on gap-menu-row hover, quiet on the selected clip's lane. */
43
+ laneGapStrips: TimelineLaneGapStrips[];
40
44
  }
41
45
 
42
46
  export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
@@ -97,7 +101,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
97
101
  return (
98
102
  <div
99
103
  className="relative"
100
- style={{ height: props.totalH, width: GUTTER + props.trackContentWidth }}
104
+ style={{ height: props.totalH, width: GUTTER + TRACKS_LEFT_PAD + props.trackContentWidth }}
101
105
  >
102
106
  <TimelineRuler
103
107
  major={props.major}
@@ -131,6 +135,31 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
131
135
  new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
132
136
  <div aria-hidden="true" style={{ height: TRACKS_BOTTOM_PAD }} />
133
137
 
138
+ {/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
139
+ menu row would collapse; a quiet tint for every gap on the selected
140
+ clip's lane. Geometry mirrors the drop placeholder (row top + clip
141
+ inset) so strips sit exactly where a clip body would. */}
142
+ {props.laneGapStrips.map((strip) => {
143
+ const rowIndex = displayTrackOrder.indexOf(strip.track);
144
+ if (rowIndex < 0) return null;
145
+ const loud = strip.kind === "hover";
146
+ return strip.intervals.map((gap) => (
147
+ <div
148
+ key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
149
+ className="pointer-events-none absolute"
150
+ style={{
151
+ top: getTimelineRowTop(rowIndex) + CLIP_Y,
152
+ left: GUTTER + TRACKS_LEFT_PAD + gap.start * props.pps,
153
+ width: Math.max((gap.end - gap.start) * props.pps, 2),
154
+ height: TRACK_H - CLIP_Y * 2,
155
+ background: loud ? "rgba(60,230,172,0.18)" : "rgba(60,230,172,0.055)",
156
+ borderRadius: 4,
157
+ zIndex: 25,
158
+ }}
159
+ />
160
+ ));
161
+ })}
162
+
134
163
  {/* Drop placeholder — a clip-sized slot at the exact landing spot (target
135
164
  lane + snapped start), parallel to the ghost. Hidden in insert mode. */}
136
165
  {draggedClip?.started && draggedClip.insertRow == null && draggedRowIndex >= 0 && (
@@ -138,7 +167,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
138
167
  className="absolute pointer-events-none"
139
168
  style={{
140
169
  top: getTimelineRowTop(draggedRowIndex) + CLIP_Y,
141
- left: GUTTER + draggedClip.previewStart * props.pps,
170
+ left: GUTTER + TRACKS_LEFT_PAD + draggedClip.previewStart * props.pps,
142
171
  width: Math.max(draggedClip.element.duration * props.pps, 4),
143
172
  height: TRACK_H - CLIP_Y * 2,
144
173
  border: "1px solid rgba(60,230,172,0.55)",
@@ -156,7 +185,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
156
185
  className="absolute pointer-events-none"
157
186
  style={{
158
187
  top: getTimelineRowTop(draggedClip.insertRow) - 0.5,
159
- left: GUTTER,
188
+ left: GUTTER + TRACKS_LEFT_PAD,
160
189
  width: props.trackContentWidth,
161
190
  height: 1,
162
191
  background: "#3CE6AC",
@@ -171,7 +200,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
171
200
  <div
172
201
  className="absolute pointer-events-none"
173
202
  style={{
174
- left: GUTTER + draggedClip.snapTime * props.pps,
203
+ left: GUTTER + TRACKS_LEFT_PAD + draggedClip.snapTime * props.pps,
175
204
  top: RULER_H,
176
205
  bottom: 0,
177
206
  width: 1,
@@ -251,7 +280,9 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
251
280
  className="absolute pointer-events-none"
252
281
  style={{
253
282
  left:
254
- GUTTER + Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps,
283
+ GUTTER +
284
+ TRACKS_LEFT_PAD +
285
+ Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps,
255
286
  width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps,
256
287
  top: RULER_H,
257
288
  bottom: 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,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={{