@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.
- package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
- package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
- package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +15 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5954 -4474
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +143 -78
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +174 -81
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +302 -154
- package/src/hooks/useTimelineEditing.ts +97 -93
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +35 -3
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/dist/assets/index-_pqzyxB1.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /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 {
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
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",
|
|
121
|
-
window.addEventListener("keyup",
|
|
118
|
+
window.addEventListener("keydown", key);
|
|
119
|
+
window.addEventListener("keyup", key);
|
|
122
120
|
window.addEventListener("blur", blur);
|
|
123
121
|
return () => {
|
|
124
|
-
window.removeEventListener("keydown",
|
|
125
|
-
window.removeEventListener("keyup",
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 +
|
|
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={{
|
|
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
|
|
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={{
|