@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.
- package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
- package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-C4csZims.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 +41 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7436 -5423
- 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 +166 -2
- package/src/components/editor/CanvasContextMenu.tsx +100 -20
- package/src/components/editor/DomEditOverlay.tsx +34 -62
- package/src/components/editor/LayersPanel.tsx +111 -91
- package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
- 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/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +61 -16
- 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/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +29 -2
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +183 -1
- package/src/hooks/timelineEditingHelpers.ts +63 -259
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +7 -4
- package/src/hooks/timelineTimingSync.test.ts +820 -0
- package/src/hooks/timelineTimingSync.ts +597 -0
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +392 -23
- package/src/hooks/useDomEditCommits.ts +180 -53
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
- package/src/hooks/useElementLifecycleOps.ts +175 -69
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +591 -277
- package/src/hooks/useTimelineEditing.ts +202 -282
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +187 -134
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +105 -18
- package/src/player/components/Timeline.tsx +69 -98
- package/src/player/components/TimelineCanvas.tsx +47 -7
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +70 -47
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +370 -11
- package/src/player/components/timelineClipDragCommit.ts +267 -68
- 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 +119 -30
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +102 -1
- package/src/player/components/timelineStackingSync.ts +45 -5
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- 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/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
- package/src/player/components/useTimelineStackingSync.ts +17 -4
- 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 +19 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/lib/timelineElementHelpers.ts +54 -11
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +49 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -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/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /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
|
-
{/*
|
|
42
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
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="
|
|
76
|
+
className="sticky top-0 flex"
|
|
87
77
|
style={{
|
|
88
78
|
height: RULER_H,
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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,
|