@hyperframes/studio 0.6.0-alpha.9 → 0.6.1
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-CzwFysqv.js +418 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +421 -4303
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +167 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +88 -993
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1117
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +84 -1049
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +68 -155
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +196 -1518
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +105 -1371
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -1,382 +1,71 @@
|
|
|
1
1
|
import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode } from "react";
|
|
2
|
-
import {
|
|
3
|
-
usePlayerStore,
|
|
4
|
-
liveTime,
|
|
5
|
-
type TimelineElement,
|
|
6
|
-
type ZoomMode,
|
|
7
|
-
} from "../store/playerStore";
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
8
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
9
|
-
import { formatTime } from "../lib/time";
|
|
10
|
-
import { TimelineClip } from "./TimelineClip";
|
|
11
4
|
import { EditPopover } from "./EditModal";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
resolveTimelineResize,
|
|
19
|
-
type BlockedTimelineEditIntent,
|
|
20
|
-
type TimelineRangeSelection,
|
|
21
|
-
} from "./timelineEditing";
|
|
22
|
-
import {
|
|
23
|
-
defaultTimelineTheme,
|
|
24
|
-
getRenderedTimelineElement,
|
|
25
|
-
getTimelineTrackStyle,
|
|
26
|
-
type TimelineTrackStyle,
|
|
27
|
-
type TimelineTheme,
|
|
28
|
-
} from "./timelineTheme";
|
|
29
|
-
import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
5
|
+
import { type BlockedTimelineEditIntent } from "./timelineEditing";
|
|
6
|
+
import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
|
|
7
|
+
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
|
8
|
+
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
|
9
|
+
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
|
|
10
|
+
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
30
11
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
12
|
+
import { TimelineEmptyState } from "./TimelineEmptyState";
|
|
13
|
+
import { TimelineCanvas } from "./TimelineCanvas";
|
|
14
|
+
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
31
15
|
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
src={src}
|
|
55
|
-
alt=""
|
|
56
|
-
width={12}
|
|
57
|
-
height={12}
|
|
58
|
-
style={{ filter: "brightness(0) invert(1)" }}
|
|
59
|
-
draggable={false}
|
|
60
|
-
/>
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
const IconCaptions = <TimelineIcon src={`${ICON_BASE}/captions.svg`} />;
|
|
64
|
-
const IconImage = <TimelineIcon src={`${ICON_BASE}/image.svg`} />;
|
|
65
|
-
const IconMusic = <TimelineIcon src={`${ICON_BASE}/music.svg`} />;
|
|
66
|
-
const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
|
|
67
|
-
const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
|
|
68
|
-
const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
|
|
69
|
-
|
|
70
|
-
const ICONS: Record<string, ReactNode> = {
|
|
71
|
-
video: IconImage,
|
|
72
|
-
audio: IconMusic,
|
|
73
|
-
img: IconImage,
|
|
74
|
-
div: IconComposition,
|
|
75
|
-
span: IconCaptions,
|
|
76
|
-
p: IconText,
|
|
77
|
-
h1: IconText,
|
|
78
|
-
section: IconComposition,
|
|
79
|
-
sfx: IconAudio,
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
function getStyle(tag: string): TrackVisualStyle {
|
|
83
|
-
const trackStyle = getTimelineTrackStyle(tag);
|
|
84
|
-
const normalized = tag.toLowerCase();
|
|
85
|
-
const icon =
|
|
86
|
-
normalized.startsWith("h") && normalized.length === 2 && "123456".includes(normalized[1] ?? "")
|
|
87
|
-
? ICONS.h1
|
|
88
|
-
: (ICONS[normalized] ?? IconComposition);
|
|
89
|
-
return {
|
|
90
|
-
...trackStyle,
|
|
91
|
-
icon,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/* ── Tick Generation ────────────────────────────────────────────── */
|
|
96
|
-
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
|
|
97
|
-
const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
|
98
|
-
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
|
99
|
-
const targetMajorPx = 128;
|
|
100
|
-
return (
|
|
101
|
-
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
|
105
|
-
const target = duration / 6;
|
|
106
|
-
return durationIntervals.find((interval) => interval >= target) ?? 60;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
|
|
110
|
-
let interval = majorInterval / 2;
|
|
111
|
-
if (majorInterval >= 30) interval = majorInterval / 6;
|
|
112
|
-
else if (majorInterval >= 15) interval = majorInterval / 3;
|
|
113
|
-
else if (majorInterval >= 5) interval = majorInterval / 5;
|
|
114
|
-
else if (majorInterval >= 1) interval = majorInterval / 4;
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
Number.isFinite(pixelsPerSecond) &&
|
|
118
|
-
(pixelsPerSecond ?? 0) > 0 &&
|
|
119
|
-
interval * (pixelsPerSecond ?? 0) < 20
|
|
120
|
-
) {
|
|
121
|
-
return Math.max(0.25, majorInterval / 2);
|
|
122
|
-
}
|
|
123
|
-
return Math.max(0.25, interval);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function generateTicks(
|
|
127
|
-
duration: number,
|
|
128
|
-
pixelsPerSecond?: number,
|
|
129
|
-
): { major: number[]; minor: number[] } {
|
|
130
|
-
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
|
|
131
|
-
return { major: [], minor: [] };
|
|
132
|
-
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
|
|
133
|
-
const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
|
|
134
|
-
const major: number[] = [];
|
|
135
|
-
const minor: number[] = [];
|
|
136
|
-
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
|
137
|
-
for (
|
|
138
|
-
let t = 0;
|
|
139
|
-
t <= duration + 0.001 && major.length + minor.length < maxTicks;
|
|
140
|
-
t += minorInterval
|
|
141
|
-
) {
|
|
142
|
-
const rounded = Math.round(t * 100) / 100;
|
|
143
|
-
const isMajor =
|
|
144
|
-
Math.abs(rounded % majorInterval) < 0.01 ||
|
|
145
|
-
Math.abs((rounded % majorInterval) - majorInterval) < 0.01;
|
|
146
|
-
if (isMajor) major.push(rounded);
|
|
147
|
-
else minor.push(rounded);
|
|
148
|
-
}
|
|
149
|
-
return { major, minor };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
|
153
|
-
if (!Number.isFinite(time)) return "0:00";
|
|
154
|
-
const safeTime = Math.max(0, time);
|
|
155
|
-
if (majorInterval < 1) {
|
|
156
|
-
const totalTenths = Math.round(safeTime * 10);
|
|
157
|
-
const wholeSeconds = Math.floor(totalTenths / 10);
|
|
158
|
-
const tenth = totalTenths % 10;
|
|
159
|
-
return `${formatTime(wholeSeconds)}.${tenth}`;
|
|
160
|
-
}
|
|
161
|
-
if (duration >= 3600 || safeTime >= 3600) {
|
|
162
|
-
const totalSeconds = Math.floor(safeTime);
|
|
163
|
-
const hours = Math.floor(totalSeconds / 3600);
|
|
164
|
-
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
165
|
-
const seconds = totalSeconds % 60;
|
|
166
|
-
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
167
|
-
}
|
|
168
|
-
return formatTime(safeTime);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function shouldAutoScrollTimeline(
|
|
172
|
-
zoomMode: ZoomMode,
|
|
173
|
-
scrollWidth: number,
|
|
174
|
-
clientWidth: number,
|
|
175
|
-
): boolean {
|
|
176
|
-
if (zoomMode === "fit") return false;
|
|
177
|
-
if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false;
|
|
178
|
-
return scrollWidth - clientWidth > 1;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function getTimelineScrollLeftForZoomTransition(
|
|
182
|
-
previousZoomMode: ZoomMode | null,
|
|
183
|
-
nextZoomMode: ZoomMode,
|
|
184
|
-
currentScrollLeft: number,
|
|
185
|
-
): number {
|
|
186
|
-
if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
|
|
187
|
-
return currentScrollLeft;
|
|
188
|
-
}
|
|
16
|
+
GUTTER,
|
|
17
|
+
TRACK_H,
|
|
18
|
+
generateTicks,
|
|
19
|
+
getTimelineCanvasHeight,
|
|
20
|
+
shouldShowTimelineShortcutHint,
|
|
21
|
+
resolveTimelineAssetDrop,
|
|
22
|
+
} from "./timelineLayout";
|
|
23
|
+
|
|
24
|
+
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
|
25
|
+
export {
|
|
26
|
+
generateTicks,
|
|
27
|
+
formatTimelineTickLabel,
|
|
28
|
+
shouldAutoScrollTimeline,
|
|
29
|
+
getTimelineScrollLeftForZoomTransition,
|
|
30
|
+
getTimelineScrollLeftForZoomAnchor,
|
|
31
|
+
getTimelinePlayheadLeft,
|
|
32
|
+
getTimelineCanvasHeight,
|
|
33
|
+
shouldShowTimelineShortcutHint,
|
|
34
|
+
resolveTimelineAssetDrop,
|
|
35
|
+
shouldHandleTimelineDeleteKey,
|
|
36
|
+
getDefaultDroppedTrack,
|
|
37
|
+
} from "./timelineLayout";
|
|
189
38
|
|
|
190
|
-
export function getTimelineScrollLeftForZoomAnchor(input: {
|
|
191
|
-
pointerX: number;
|
|
192
|
-
currentScrollLeft: number;
|
|
193
|
-
gutter: number;
|
|
194
|
-
currentPixelsPerSecond: number;
|
|
195
|
-
nextPixelsPerSecond: number;
|
|
196
|
-
duration: number;
|
|
197
|
-
}): number {
|
|
198
|
-
const currentPps = Math.max(0, input.currentPixelsPerSecond);
|
|
199
|
-
const nextPps = Math.max(0, input.nextPixelsPerSecond);
|
|
200
|
-
if (
|
|
201
|
-
!Number.isFinite(input.pointerX) ||
|
|
202
|
-
!Number.isFinite(input.currentScrollLeft) ||
|
|
203
|
-
!Number.isFinite(input.duration) ||
|
|
204
|
-
input.duration <= 0 ||
|
|
205
|
-
currentPps <= 0 ||
|
|
206
|
-
nextPps <= 0
|
|
207
|
-
) {
|
|
208
|
-
return Math.max(0, input.currentScrollLeft);
|
|
209
|
-
}
|
|
210
|
-
const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
|
|
211
|
-
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
|
|
212
|
-
return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
|
216
|
-
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
|
|
217
|
-
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
export function getTimelineCanvasHeight(trackCount: number): number {
|
|
221
|
-
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export function shouldShowTimelineShortcutHint(
|
|
225
|
-
scrollHeight: number,
|
|
226
|
-
clientHeight: number,
|
|
227
|
-
): boolean {
|
|
228
|
-
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
|
|
229
|
-
return scrollHeight - clientHeight <= 1;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export function shouldHandleTimelineDeleteKey(input: {
|
|
233
|
-
key: string;
|
|
234
|
-
metaKey?: boolean;
|
|
235
|
-
ctrlKey?: boolean;
|
|
236
|
-
altKey?: boolean;
|
|
237
|
-
target?: EventTarget | null;
|
|
238
|
-
}): boolean {
|
|
239
|
-
if (input.key !== "Delete" && input.key !== "Backspace") return false;
|
|
240
|
-
if (input.metaKey || input.ctrlKey || input.altKey) return false;
|
|
241
|
-
const target =
|
|
242
|
-
input.target && typeof input.target === "object"
|
|
243
|
-
? (input.target as {
|
|
244
|
-
tagName?: string;
|
|
245
|
-
isContentEditable?: boolean;
|
|
246
|
-
closest?: (selector: string) => Element | null;
|
|
247
|
-
})
|
|
248
|
-
: null;
|
|
249
|
-
if (target) {
|
|
250
|
-
const tag = target.tagName?.toLowerCase() ?? "";
|
|
251
|
-
if (target.isContentEditable) return false;
|
|
252
|
-
if (["input", "textarea", "select"].includes(tag)) return false;
|
|
253
|
-
if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
|
|
254
|
-
return false;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return true;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
|
|
261
|
-
if (trackOrder.length === 0) return 0;
|
|
262
|
-
if (rowIndex == null || rowIndex < 0) return trackOrder[0];
|
|
263
|
-
if (rowIndex >= trackOrder.length) {
|
|
264
|
-
return Math.max(...trackOrder) + 1;
|
|
265
|
-
}
|
|
266
|
-
return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
export function resolveTimelineAssetDrop(
|
|
270
|
-
input: {
|
|
271
|
-
rectLeft: number;
|
|
272
|
-
rectTop: number;
|
|
273
|
-
scrollLeft: number;
|
|
274
|
-
scrollTop: number;
|
|
275
|
-
pixelsPerSecond: number;
|
|
276
|
-
duration: number;
|
|
277
|
-
trackHeight: number;
|
|
278
|
-
trackOrder: number[];
|
|
279
|
-
},
|
|
280
|
-
clientX: number,
|
|
281
|
-
clientY: number,
|
|
282
|
-
): { start: number; track: number } {
|
|
283
|
-
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
|
|
284
|
-
const y = clientY - input.rectTop + input.scrollTop - RULER_H;
|
|
285
|
-
const start = Math.max(
|
|
286
|
-
0,
|
|
287
|
-
Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
|
|
288
|
-
);
|
|
289
|
-
const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1));
|
|
290
|
-
return {
|
|
291
|
-
start,
|
|
292
|
-
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
/* ── Component ──────────────────────────────────────────────────── */
|
|
296
39
|
interface TimelineProps {
|
|
297
|
-
/** Called when user seeks via ruler/track click or playhead drag */
|
|
298
40
|
onSeek?: (time: number) => void;
|
|
299
|
-
|
|
300
|
-
onDrillDown?: (element: import("../store/playerStore").TimelineElement) => void;
|
|
301
|
-
/** Optional custom content renderer for clips (thumbnails, waveforms, etc.) */
|
|
41
|
+
onDrillDown?: (element: TimelineElement) => void;
|
|
302
42
|
renderClipContent?: (
|
|
303
|
-
element:
|
|
43
|
+
element: TimelineElement,
|
|
304
44
|
style: { clip: string; label: string },
|
|
305
45
|
) => ReactNode;
|
|
306
|
-
|
|
307
|
-
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
|
|
308
|
-
/** Called when files are dropped onto the empty timeline */
|
|
46
|
+
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
309
47
|
onFileDrop?: (
|
|
310
48
|
files: File[],
|
|
311
49
|
placement?: { start: number; track: number },
|
|
312
50
|
) => Promise<void> | void;
|
|
313
|
-
/** Called when an existing asset is dropped from the Assets tab */
|
|
314
51
|
onAssetDrop?: (
|
|
315
52
|
assetPath: string,
|
|
316
53
|
placement: { start: number; track: number },
|
|
317
54
|
) => Promise<void> | void;
|
|
318
|
-
|
|
319
|
-
onDeleteElement?: (
|
|
320
|
-
element: import("../store/playerStore").TimelineElement,
|
|
321
|
-
) => Promise<void> | void;
|
|
55
|
+
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
322
56
|
onMoveElement?: (
|
|
323
|
-
element:
|
|
324
|
-
updates: Pick<
|
|
57
|
+
element: TimelineElement,
|
|
58
|
+
updates: Pick<TimelineElement, "start" | "track">,
|
|
325
59
|
) => Promise<void> | void;
|
|
326
60
|
onResizeElement?: (
|
|
327
|
-
element:
|
|
328
|
-
updates: Pick<
|
|
329
|
-
import("../store/playerStore").TimelineElement,
|
|
330
|
-
"start" | "duration" | "playbackStart"
|
|
331
|
-
>,
|
|
61
|
+
element: TimelineElement,
|
|
62
|
+
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
332
63
|
) => Promise<void> | void;
|
|
333
|
-
onBlockedEditAttempt?: (
|
|
334
|
-
|
|
335
|
-
intent: BlockedTimelineEditIntent,
|
|
336
|
-
) => void;
|
|
337
|
-
onSelectElement?: (element: import("../store/playerStore").TimelineElement | null) => void;
|
|
338
|
-
onInspectElement?: (element: import("../store/playerStore").TimelineElement) => void;
|
|
339
|
-
inspectedElementId?: string | null;
|
|
340
|
-
layerChildCounts?: ReadonlyMap<string, number>;
|
|
341
|
-
thumbnailedElementIds?: ReadonlySet<string>;
|
|
342
|
-
onToggleElementThumbnail?: (element: import("../store/playerStore").TimelineElement) => void;
|
|
343
|
-
disabled?: boolean;
|
|
64
|
+
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
65
|
+
onSelectElement?: (element: TimelineElement | null) => void;
|
|
344
66
|
theme?: Partial<TimelineTheme>;
|
|
345
67
|
}
|
|
346
68
|
|
|
347
|
-
interface DraggedClipState {
|
|
348
|
-
element: TimelineElement;
|
|
349
|
-
originClientX: number;
|
|
350
|
-
originClientY: number;
|
|
351
|
-
originScrollLeft: number;
|
|
352
|
-
originScrollTop: number;
|
|
353
|
-
pointerClientX: number;
|
|
354
|
-
pointerClientY: number;
|
|
355
|
-
pointerOffsetX: number;
|
|
356
|
-
pointerOffsetY: number;
|
|
357
|
-
previewStart: number;
|
|
358
|
-
previewTrack: number;
|
|
359
|
-
started: boolean;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
interface ResizingClipState {
|
|
363
|
-
element: TimelineElement;
|
|
364
|
-
edge: "start" | "end";
|
|
365
|
-
originClientX: number;
|
|
366
|
-
previewStart: number;
|
|
367
|
-
previewDuration: number;
|
|
368
|
-
previewPlaybackStart?: number;
|
|
369
|
-
started: boolean;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
interface BlockedClipState {
|
|
373
|
-
element: TimelineElement;
|
|
374
|
-
intent: BlockedTimelineEditIntent;
|
|
375
|
-
originClientX: number;
|
|
376
|
-
originClientY: number;
|
|
377
|
-
started: boolean;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
69
|
export const Timeline = memo(function Timeline({
|
|
381
70
|
onSeek,
|
|
382
71
|
onDrillDown,
|
|
@@ -384,17 +73,11 @@ export const Timeline = memo(function Timeline({
|
|
|
384
73
|
renderClipOverlay,
|
|
385
74
|
onFileDrop,
|
|
386
75
|
onAssetDrop,
|
|
387
|
-
onDeleteElement,
|
|
76
|
+
onDeleteElement: _onDeleteElement,
|
|
388
77
|
onMoveElement,
|
|
389
78
|
onResizeElement,
|
|
390
79
|
onBlockedEditAttempt,
|
|
391
80
|
onSelectElement,
|
|
392
|
-
onInspectElement,
|
|
393
|
-
inspectedElementId,
|
|
394
|
-
layerChildCounts,
|
|
395
|
-
thumbnailedElementIds,
|
|
396
|
-
onToggleElementThumbnail,
|
|
397
|
-
disabled = false,
|
|
398
81
|
theme: themeOverrides,
|
|
399
82
|
}: TimelineProps = {}) {
|
|
400
83
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
@@ -403,26 +86,19 @@ export const Timeline = memo(function Timeline({
|
|
|
403
86
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
404
87
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
405
88
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
406
|
-
const updateElement = usePlayerStore((s) => s.updateElement);
|
|
407
89
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
408
90
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
409
91
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
410
92
|
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
411
93
|
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
94
|
+
|
|
412
95
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
413
96
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
414
97
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
415
98
|
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
|
416
99
|
const isDragging = useRef(false);
|
|
417
|
-
const disabledRef = useRef(disabled);
|
|
418
|
-
disabledRef.current = disabled;
|
|
419
|
-
const shiftClickClipRef = useRef<{
|
|
420
|
-
element: TimelineElement;
|
|
421
|
-
anchorX: number;
|
|
422
|
-
anchorY: number;
|
|
423
|
-
} | null>(null);
|
|
424
|
-
// Range selection (Shift+drag)
|
|
425
100
|
const [shiftHeld, setShiftHeld] = useState(false);
|
|
101
|
+
|
|
426
102
|
useMountEffect(() => {
|
|
427
103
|
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
|
|
428
104
|
const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
|
|
@@ -436,35 +112,20 @@ export const Timeline = memo(function Timeline({
|
|
|
436
112
|
window.removeEventListener("blur", blur);
|
|
437
113
|
};
|
|
438
114
|
});
|
|
439
|
-
|
|
440
|
-
const rangeAnchorTime = useRef(0);
|
|
441
|
-
const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | null>(null);
|
|
442
|
-
const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
|
|
443
|
-
const draggedClipRef = useRef<DraggedClipState | null>(null);
|
|
444
|
-
draggedClipRef.current = draggedClip;
|
|
445
|
-
const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
|
|
446
|
-
const resizingClipRef = useRef<ResizingClipState | null>(null);
|
|
447
|
-
resizingClipRef.current = resizingClip;
|
|
448
|
-
const blockedClipRef = useRef<BlockedClipState | null>(null);
|
|
449
|
-
const deleteInFlightRef = useRef(false);
|
|
450
|
-
const onMoveElementRef = useRef(onMoveElement);
|
|
451
|
-
onMoveElementRef.current = onMoveElement;
|
|
452
|
-
const onResizeElementRef = useRef(onResizeElement);
|
|
453
|
-
onResizeElementRef.current = onResizeElement;
|
|
454
|
-
const onDeleteElementRef = useRef(onDeleteElement);
|
|
455
|
-
onDeleteElementRef.current = onDeleteElement;
|
|
456
|
-
const suppressClickRef = useRef(false);
|
|
115
|
+
|
|
457
116
|
const [showPopover, setShowPopover] = useState(false);
|
|
458
117
|
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
|
459
118
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
460
119
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
461
120
|
const shortcutHintRafRef = useRef(0);
|
|
121
|
+
|
|
462
122
|
const syncShortcutHintVisibility = useCallback(() => {
|
|
463
123
|
const scroll = scrollRef.current;
|
|
464
124
|
setShowShortcutHint(
|
|
465
125
|
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
|
|
466
126
|
);
|
|
467
127
|
}, []);
|
|
128
|
+
|
|
468
129
|
const scheduleShortcutHintVisibilitySync = useCallback(() => {
|
|
469
130
|
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
470
131
|
shortcutHintRafRef.current = requestAnimationFrame(() => {
|
|
@@ -473,10 +134,6 @@ export const Timeline = memo(function Timeline({
|
|
|
473
134
|
});
|
|
474
135
|
}, [syncShortcutHintVisibility]);
|
|
475
136
|
|
|
476
|
-
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
|
|
477
|
-
// useMountEffect can't work here because the component returns null on first
|
|
478
|
-
// render (timelineReady=false), so containerRef.current is null when the
|
|
479
|
-
// effect fires and the ResizeObserver is never created.
|
|
480
137
|
const setContainerRef = useCallback(
|
|
481
138
|
(el: HTMLDivElement | null) => {
|
|
482
139
|
if (roRef.current) {
|
|
@@ -496,28 +153,11 @@ export const Timeline = memo(function Timeline({
|
|
|
496
153
|
[scheduleShortcutHintVisibilitySync],
|
|
497
154
|
);
|
|
498
155
|
|
|
499
|
-
// Clean up ResizeObserver on unmount
|
|
500
156
|
useMountEffect(() => () => {
|
|
501
157
|
roRef.current?.disconnect();
|
|
502
158
|
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
503
159
|
});
|
|
504
160
|
|
|
505
|
-
useEffect(() => {
|
|
506
|
-
if (!disabled) return;
|
|
507
|
-
stopClipDragAutoScrollRef.current();
|
|
508
|
-
isDragging.current = false;
|
|
509
|
-
isRangeSelecting.current = false;
|
|
510
|
-
blockedClipRef.current = null;
|
|
511
|
-
setDraggedClip(null);
|
|
512
|
-
setResizingClip(null);
|
|
513
|
-
setRangeSelection(null);
|
|
514
|
-
setShowPopover(false);
|
|
515
|
-
setIsDragOver(false);
|
|
516
|
-
}, [disabled]);
|
|
517
|
-
|
|
518
|
-
// Effective duration: max of store duration and the furthest element end.
|
|
519
|
-
// processTimelineMessage updates elements but not duration, so elements can
|
|
520
|
-
// extend beyond the store's duration — this ensures fit mode shows everything.
|
|
521
161
|
const effectiveDuration = useMemo(() => {
|
|
522
162
|
const safeDur = Number.isFinite(duration) ? duration : 0;
|
|
523
163
|
if (elements.length === 0) return safeDur;
|
|
@@ -539,7 +179,7 @@ export const Timeline = memo(function Timeline({
|
|
|
539
179
|
const trackStyles = useMemo(() => {
|
|
540
180
|
const map = new Map<number, TrackVisualStyle>();
|
|
541
181
|
for (const [trackNum, els] of tracks) {
|
|
542
|
-
map.set(trackNum,
|
|
182
|
+
map.set(trackNum, getTrackStyle(els[0]?.tag ?? ""));
|
|
543
183
|
}
|
|
544
184
|
return map;
|
|
545
185
|
}, [tracks]);
|
|
@@ -547,16 +187,44 @@ export const Timeline = memo(function Timeline({
|
|
|
547
187
|
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
|
|
548
188
|
const trackOrderRef = useRef(trackOrder);
|
|
549
189
|
trackOrderRef.current = trackOrder;
|
|
190
|
+
|
|
191
|
+
const ppsRef = useRef(100);
|
|
192
|
+
const durationRef = useRef(effectiveDuration);
|
|
193
|
+
durationRef.current = effectiveDuration;
|
|
194
|
+
|
|
195
|
+
// Stable ref so useTimelineClipDrag can clear rangeSelection without circular dep
|
|
196
|
+
const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null);
|
|
197
|
+
|
|
198
|
+
const {
|
|
199
|
+
draggedClip,
|
|
200
|
+
setDraggedClip,
|
|
201
|
+
resizingClip,
|
|
202
|
+
setResizingClip,
|
|
203
|
+
blockedClipRef,
|
|
204
|
+
suppressClickRef,
|
|
205
|
+
syncClipDragAutoScroll,
|
|
206
|
+
} = useTimelineClipDrag({
|
|
207
|
+
scrollRef,
|
|
208
|
+
ppsRef,
|
|
209
|
+
durationRef,
|
|
210
|
+
trackOrderRef,
|
|
211
|
+
onMoveElement,
|
|
212
|
+
onResizeElement,
|
|
213
|
+
onBlockedEditAttempt,
|
|
214
|
+
setShowPopover,
|
|
215
|
+
setRangeSelectionRef,
|
|
216
|
+
});
|
|
217
|
+
|
|
550
218
|
const displayTrackOrder = useMemo(() => {
|
|
551
219
|
if (
|
|
552
220
|
!draggedClip?.started ||
|
|
553
221
|
trackOrder.length === 0 ||
|
|
554
222
|
trackOrder.includes(draggedClip.previewTrack)
|
|
555
|
-
)
|
|
223
|
+
)
|
|
556
224
|
return trackOrder;
|
|
557
|
-
}
|
|
558
225
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
559
226
|
}, [draggedClip, trackOrder]);
|
|
227
|
+
|
|
560
228
|
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
561
229
|
const selectedElement = useMemo(
|
|
562
230
|
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
@@ -565,517 +233,75 @@ export const Timeline = memo(function Timeline({
|
|
|
565
233
|
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
|
|
566
234
|
selectedElementRef.current = selectedElement;
|
|
567
235
|
|
|
568
|
-
// Calculate effective pixels per second
|
|
569
|
-
// In fit mode, use clientWidth (excludes scrollbar) with a small padding
|
|
570
236
|
const fitPps =
|
|
571
237
|
viewportWidth > GUTTER && effectiveDuration > 0
|
|
572
238
|
? (viewportWidth - GUTTER - 2) / effectiveDuration
|
|
573
239
|
: 100;
|
|
574
240
|
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
|
|
241
|
+
ppsRef.current = pps;
|
|
575
242
|
const trackContentWidth = Math.max(0, effectiveDuration * pps);
|
|
576
243
|
const zoomModeRef = useRef(zoomMode);
|
|
577
244
|
zoomModeRef.current = zoomMode;
|
|
578
245
|
const manualZoomPercentRef = useRef(manualZoomPercent);
|
|
579
246
|
manualZoomPercentRef.current = manualZoomPercent;
|
|
580
|
-
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
|
581
247
|
const fitPpsRef = useRef(fitPps);
|
|
582
248
|
fitPpsRef.current = fitPps;
|
|
583
249
|
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
previousZoomModeRef.current,
|
|
605
|
-
zoomMode,
|
|
606
|
-
scroll.scrollLeft,
|
|
607
|
-
);
|
|
608
|
-
previousZoomModeRef.current = zoomMode;
|
|
609
|
-
}, [zoomMode]);
|
|
610
|
-
useMountEffect(() => {
|
|
611
|
-
const unsub = liveTime.subscribe((t) => {
|
|
612
|
-
const dur = durationRef.current;
|
|
613
|
-
if (!playheadRef.current || dur <= 0) return;
|
|
614
|
-
const playheadX = getTimelinePlayheadLeft(t, ppsRef.current);
|
|
615
|
-
playheadRef.current.style.left = `${playheadX}px`;
|
|
616
|
-
|
|
617
|
-
// Auto-scroll to follow playhead during playback or seeking
|
|
618
|
-
const scroll = scrollRef.current;
|
|
619
|
-
if (
|
|
620
|
-
scroll &&
|
|
621
|
-
!isDragging.current &&
|
|
622
|
-
shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth)
|
|
623
|
-
) {
|
|
624
|
-
const visibleRight = scroll.scrollLeft + scroll.clientWidth;
|
|
625
|
-
const visibleLeft = scroll.scrollLeft;
|
|
626
|
-
const edgeMargin = scroll.clientWidth * 0.12;
|
|
627
|
-
|
|
628
|
-
if (playheadX > visibleRight - edgeMargin) {
|
|
629
|
-
// Playhead near right edge — page forward
|
|
630
|
-
scroll.scrollLeft = playheadX - scroll.clientWidth * 0.15;
|
|
631
|
-
} else if (playheadX < visibleLeft + GUTTER) {
|
|
632
|
-
// Playhead before visible area (e.g. loop) — jump back
|
|
633
|
-
scroll.scrollLeft = Math.max(0, playheadX - GUTTER);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
});
|
|
637
|
-
return unsub;
|
|
250
|
+
const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({
|
|
251
|
+
playheadRef,
|
|
252
|
+
scrollRef,
|
|
253
|
+
ppsRef,
|
|
254
|
+
durationRef,
|
|
255
|
+
isDragging,
|
|
256
|
+
currentTime,
|
|
257
|
+
zoomMode,
|
|
258
|
+
manualZoomPercent,
|
|
259
|
+
zoomModeRef,
|
|
260
|
+
manualZoomPercentRef,
|
|
261
|
+
fitPps,
|
|
262
|
+
fitPpsRef,
|
|
263
|
+
effectiveDuration,
|
|
264
|
+
pps,
|
|
265
|
+
timelineReady,
|
|
266
|
+
elementsLength: elements.length,
|
|
267
|
+
setZoomMode,
|
|
268
|
+
setManualZoomPercent,
|
|
269
|
+
onSeek,
|
|
638
270
|
});
|
|
639
271
|
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
pixelsPerSecond: ppsRef.current,
|
|
659
|
-
trackHeight: TRACK_H,
|
|
660
|
-
maxStart: Math.max(0, durationRef.current - drag.element.duration),
|
|
661
|
-
trackOrder: trackOrderRef.current,
|
|
662
|
-
},
|
|
663
|
-
clientX,
|
|
664
|
-
clientY,
|
|
665
|
-
);
|
|
666
|
-
|
|
667
|
-
return {
|
|
668
|
-
...drag,
|
|
669
|
-
started: true,
|
|
670
|
-
pointerClientX: clientX,
|
|
671
|
-
pointerClientY: clientY,
|
|
672
|
-
previewStart: nextMove.start,
|
|
673
|
-
previewTrack: nextMove.track,
|
|
674
|
-
};
|
|
675
|
-
},
|
|
676
|
-
[],
|
|
677
|
-
);
|
|
678
|
-
|
|
679
|
-
const stopClipDragAutoScroll = useCallback(() => {
|
|
680
|
-
clipDragPointerRef.current = null;
|
|
681
|
-
if (clipDragScrollRaf.current) {
|
|
682
|
-
cancelAnimationFrame(clipDragScrollRaf.current);
|
|
683
|
-
clipDragScrollRaf.current = 0;
|
|
684
|
-
}
|
|
685
|
-
}, []);
|
|
686
|
-
|
|
687
|
-
const stepClipDragAutoScroll = useCallback(() => {
|
|
688
|
-
clipDragScrollRaf.current = 0;
|
|
689
|
-
const drag = draggedClipRef.current;
|
|
690
|
-
const pointer = clipDragPointerRef.current;
|
|
691
|
-
const scroll = scrollRef.current;
|
|
692
|
-
if (!drag || !pointer || !scroll) return;
|
|
693
|
-
|
|
694
|
-
const rect = scroll.getBoundingClientRect();
|
|
695
|
-
const delta = resolveTimelineAutoScroll(rect, pointer.clientX, pointer.clientY);
|
|
696
|
-
if (delta.x === 0 && delta.y === 0) return;
|
|
697
|
-
|
|
698
|
-
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
699
|
-
const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
|
|
700
|
-
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
|
|
701
|
-
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
|
|
702
|
-
const didScroll = nextScrollLeft !== scroll.scrollLeft || nextScrollTop !== scroll.scrollTop;
|
|
703
|
-
|
|
704
|
-
if (!didScroll) return;
|
|
705
|
-
|
|
706
|
-
scroll.scrollLeft = nextScrollLeft;
|
|
707
|
-
scroll.scrollTop = nextScrollTop;
|
|
708
|
-
setDraggedClip((prev) =>
|
|
709
|
-
prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
|
|
710
|
-
);
|
|
711
|
-
|
|
712
|
-
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
|
|
713
|
-
}, [updateDraggedClipPreview]);
|
|
714
|
-
|
|
715
|
-
const syncClipDragAutoScroll = useCallback(
|
|
716
|
-
(clientX: number, clientY: number) => {
|
|
717
|
-
clipDragPointerRef.current = { clientX, clientY };
|
|
718
|
-
const scroll = scrollRef.current;
|
|
719
|
-
if (!scroll) return;
|
|
720
|
-
const rect = scroll.getBoundingClientRect();
|
|
721
|
-
const delta = resolveTimelineAutoScroll(rect, clientX, clientY);
|
|
722
|
-
if (delta.x === 0 && delta.y === 0) {
|
|
723
|
-
if (clipDragScrollRaf.current) {
|
|
724
|
-
cancelAnimationFrame(clipDragScrollRaf.current);
|
|
725
|
-
clipDragScrollRaf.current = 0;
|
|
726
|
-
}
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
if (!clipDragScrollRaf.current) {
|
|
730
|
-
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
|
|
731
|
-
}
|
|
732
|
-
},
|
|
733
|
-
[stepClipDragAutoScroll],
|
|
734
|
-
);
|
|
735
|
-
const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview);
|
|
736
|
-
updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
|
|
737
|
-
const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
|
|
738
|
-
syncClipDragAutoScrollRef.current = syncClipDragAutoScroll;
|
|
739
|
-
const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
|
|
740
|
-
stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
|
|
741
|
-
|
|
742
|
-
const seekFromX = useCallback(
|
|
743
|
-
(clientX: number) => {
|
|
744
|
-
if (disabledRef.current) return;
|
|
745
|
-
const el = scrollRef.current;
|
|
746
|
-
if (!el || effectiveDuration <= 0) return;
|
|
747
|
-
const rect = el.getBoundingClientRect();
|
|
748
|
-
const scrollLeft = el.scrollLeft;
|
|
749
|
-
const x = clientX - rect.left + scrollLeft - GUTTER;
|
|
750
|
-
if (x < 0) return;
|
|
751
|
-
const time = Math.max(0, Math.min(effectiveDuration, x / pps));
|
|
752
|
-
liveTime.notify(time);
|
|
753
|
-
onSeek?.(time);
|
|
754
|
-
},
|
|
755
|
-
[effectiveDuration, onSeek, pps],
|
|
756
|
-
);
|
|
757
|
-
|
|
758
|
-
// Auto-scroll the timeline when dragging the playhead near edges
|
|
759
|
-
const autoScrollDuringDrag = useCallback(
|
|
760
|
-
(clientX: number) => {
|
|
761
|
-
cancelAnimationFrame(dragScrollRaf.current);
|
|
762
|
-
const el = scrollRef.current;
|
|
763
|
-
if (
|
|
764
|
-
!el ||
|
|
765
|
-
!isDragging.current ||
|
|
766
|
-
!shouldAutoScrollTimeline(zoomModeRef.current, el.scrollWidth, el.clientWidth)
|
|
767
|
-
) {
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
const rect = el.getBoundingClientRect();
|
|
771
|
-
const edgeZone = 40;
|
|
772
|
-
const maxSpeed = 12;
|
|
773
|
-
let scrollDelta = 0;
|
|
774
|
-
|
|
775
|
-
if (clientX < rect.left + edgeZone) {
|
|
776
|
-
// Near left edge — scroll left
|
|
777
|
-
const proximity = Math.max(0, 1 - (clientX - rect.left) / edgeZone);
|
|
778
|
-
scrollDelta = -maxSpeed * proximity;
|
|
779
|
-
} else if (clientX > rect.right - edgeZone) {
|
|
780
|
-
// Near right edge — scroll right
|
|
781
|
-
const proximity = Math.max(0, 1 - (rect.right - clientX) / edgeZone);
|
|
782
|
-
scrollDelta = maxSpeed * proximity;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
if (scrollDelta !== 0) {
|
|
786
|
-
el.scrollLeft += scrollDelta;
|
|
787
|
-
seekFromX(clientX);
|
|
788
|
-
dragScrollRaf.current = requestAnimationFrame(() => autoScrollDuringDrag(clientX));
|
|
789
|
-
}
|
|
790
|
-
},
|
|
791
|
-
[seekFromX],
|
|
792
|
-
);
|
|
793
|
-
|
|
794
|
-
useMountEffect(() => {
|
|
795
|
-
const clearSuppressedClick = () => {
|
|
796
|
-
requestAnimationFrame(() => {
|
|
797
|
-
suppressClickRef.current = false;
|
|
798
|
-
});
|
|
799
|
-
};
|
|
800
|
-
|
|
801
|
-
const handleWindowPointerMove = (e: PointerEvent) => {
|
|
802
|
-
if (disabledRef.current) return;
|
|
803
|
-
const drag = draggedClipRef.current;
|
|
804
|
-
const resize = resizingClipRef.current;
|
|
805
|
-
const blocked = blockedClipRef.current;
|
|
806
|
-
if (resize) {
|
|
807
|
-
const distance = Math.abs(e.clientX - resize.originClientX);
|
|
808
|
-
if (!resize.started && distance < 2) return;
|
|
809
|
-
|
|
810
|
-
setShowPopover(false);
|
|
811
|
-
setRangeSelection(null);
|
|
812
|
-
|
|
813
|
-
const sourceRemaining =
|
|
814
|
-
resize.element.sourceDuration != null
|
|
815
|
-
? Math.max(
|
|
816
|
-
0,
|
|
817
|
-
(resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) /
|
|
818
|
-
Math.max(resize.element.playbackRate ?? 1, 0.1),
|
|
819
|
-
)
|
|
820
|
-
: Number.POSITIVE_INFINITY;
|
|
821
|
-
const normalizedTag = resize.element.tag.toLowerCase();
|
|
822
|
-
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
|
|
823
|
-
const nextResize = resolveTimelineResize(
|
|
824
|
-
{
|
|
825
|
-
start: resize.element.start,
|
|
826
|
-
duration: resize.element.duration,
|
|
827
|
-
originClientX: resize.originClientX,
|
|
828
|
-
pixelsPerSecond: ppsRef.current,
|
|
829
|
-
minStart: 0,
|
|
830
|
-
maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
|
|
831
|
-
playbackStart:
|
|
832
|
-
resize.edge === "start" && canSeedPlaybackStart
|
|
833
|
-
? (resize.element.playbackStart ?? 0)
|
|
834
|
-
: resize.element.playbackStart,
|
|
835
|
-
playbackRate: resize.element.playbackRate,
|
|
836
|
-
},
|
|
837
|
-
resize.edge,
|
|
838
|
-
e.clientX,
|
|
839
|
-
);
|
|
840
|
-
|
|
841
|
-
setResizingClip((prev) =>
|
|
842
|
-
prev
|
|
843
|
-
? {
|
|
844
|
-
...prev,
|
|
845
|
-
started: true,
|
|
846
|
-
previewStart: nextResize.start,
|
|
847
|
-
previewDuration: nextResize.duration,
|
|
848
|
-
previewPlaybackStart: nextResize.playbackStart,
|
|
849
|
-
}
|
|
850
|
-
: prev,
|
|
851
|
-
);
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
if (blocked) {
|
|
855
|
-
const distance = Math.hypot(
|
|
856
|
-
e.clientX - blocked.originClientX,
|
|
857
|
-
e.clientY - blocked.originClientY,
|
|
858
|
-
);
|
|
859
|
-
const threshold = blocked.intent === "move" ? 4 : 2;
|
|
860
|
-
if (!blocked.started && distance < threshold) return;
|
|
861
|
-
if (!blocked.started) {
|
|
862
|
-
blocked.started = true;
|
|
863
|
-
blockedClipRef.current = blocked;
|
|
864
|
-
suppressClickRef.current = true;
|
|
865
|
-
setShowPopover(false);
|
|
866
|
-
setRangeSelection(null);
|
|
867
|
-
onBlockedEditAttempt?.(blocked.element, blocked.intent);
|
|
868
|
-
}
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
if (!drag) return;
|
|
872
|
-
|
|
873
|
-
const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
|
|
874
|
-
if (!drag.started && distance < 4) return;
|
|
875
|
-
|
|
876
|
-
setShowPopover(false);
|
|
877
|
-
setRangeSelection(null);
|
|
878
|
-
|
|
879
|
-
setDraggedClip((prev) =>
|
|
880
|
-
prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev,
|
|
881
|
-
);
|
|
882
|
-
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
|
|
883
|
-
};
|
|
884
|
-
|
|
885
|
-
const handleWindowPointerUp = () => {
|
|
886
|
-
stopClipDragAutoScrollRef.current();
|
|
887
|
-
if (disabledRef.current) return;
|
|
888
|
-
const resize = resizingClipRef.current;
|
|
889
|
-
if (resize) {
|
|
890
|
-
resizingClipRef.current = null;
|
|
891
|
-
setResizingClip(null);
|
|
892
|
-
|
|
893
|
-
if (!resize.started) return;
|
|
894
|
-
|
|
895
|
-
suppressClickRef.current = true;
|
|
896
|
-
clearSuppressedClick();
|
|
897
|
-
|
|
898
|
-
const hasChanged =
|
|
899
|
-
resize.previewStart !== resize.element.start ||
|
|
900
|
-
resize.previewDuration !== resize.element.duration ||
|
|
901
|
-
resize.previewPlaybackStart !== resize.element.playbackStart;
|
|
902
|
-
if (!hasChanged) return;
|
|
903
|
-
|
|
904
|
-
updateElement(resize.element.key ?? resize.element.id, {
|
|
905
|
-
start: resize.previewStart,
|
|
906
|
-
duration: resize.previewDuration,
|
|
907
|
-
playbackStart: resize.previewPlaybackStart,
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
Promise.resolve(
|
|
911
|
-
onResizeElementRef.current?.(resize.element, {
|
|
912
|
-
start: resize.previewStart,
|
|
913
|
-
duration: resize.previewDuration,
|
|
914
|
-
playbackStart: resize.previewPlaybackStart,
|
|
915
|
-
}),
|
|
916
|
-
).catch((error) => {
|
|
917
|
-
updateElement(resize.element.key ?? resize.element.id, {
|
|
918
|
-
start: resize.element.start,
|
|
919
|
-
duration: resize.element.duration,
|
|
920
|
-
playbackStart: resize.element.playbackStart,
|
|
921
|
-
});
|
|
922
|
-
console.error("[Timeline] Failed to persist clip resize", error);
|
|
923
|
-
});
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
const blocked = blockedClipRef.current;
|
|
928
|
-
if (blocked) {
|
|
929
|
-
blockedClipRef.current = null;
|
|
930
|
-
if (!blocked.started) return;
|
|
931
|
-
clearSuppressedClick();
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
const drag = draggedClipRef.current;
|
|
936
|
-
if (!drag) return;
|
|
937
|
-
draggedClipRef.current = null;
|
|
938
|
-
setDraggedClip(null);
|
|
939
|
-
|
|
940
|
-
if (!drag.started) return;
|
|
941
|
-
|
|
942
|
-
suppressClickRef.current = true;
|
|
943
|
-
clearSuppressedClick();
|
|
944
|
-
|
|
945
|
-
const hasChanged =
|
|
946
|
-
drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track;
|
|
947
|
-
if (!hasChanged) return;
|
|
948
|
-
|
|
949
|
-
updateElement(drag.element.key ?? drag.element.id, {
|
|
950
|
-
start: drag.previewStart,
|
|
951
|
-
track: drag.previewTrack,
|
|
952
|
-
});
|
|
953
|
-
|
|
954
|
-
Promise.resolve(
|
|
955
|
-
onMoveElementRef.current?.(drag.element, {
|
|
956
|
-
start: drag.previewStart,
|
|
957
|
-
track: drag.previewTrack,
|
|
958
|
-
}),
|
|
959
|
-
).catch((error) => {
|
|
960
|
-
updateElement(drag.element.key ?? drag.element.id, {
|
|
961
|
-
start: drag.element.start,
|
|
962
|
-
track: drag.element.track,
|
|
963
|
-
});
|
|
964
|
-
console.error("[Timeline] Failed to persist clip move", error);
|
|
965
|
-
});
|
|
966
|
-
};
|
|
967
|
-
|
|
968
|
-
window.addEventListener("pointermove", handleWindowPointerMove);
|
|
969
|
-
window.addEventListener("pointerup", handleWindowPointerUp);
|
|
970
|
-
window.addEventListener("pointercancel", handleWindowPointerUp);
|
|
971
|
-
return () => {
|
|
972
|
-
stopClipDragAutoScrollRef.current();
|
|
973
|
-
window.removeEventListener("pointermove", handleWindowPointerMove);
|
|
974
|
-
window.removeEventListener("pointerup", handleWindowPointerUp);
|
|
975
|
-
window.removeEventListener("pointercancel", handleWindowPointerUp);
|
|
976
|
-
};
|
|
272
|
+
const {
|
|
273
|
+
rangeSelection,
|
|
274
|
+
setRangeSelection,
|
|
275
|
+
shiftClickClipRef,
|
|
276
|
+
handlePointerDown,
|
|
277
|
+
handlePointerMove,
|
|
278
|
+
handlePointerUp,
|
|
279
|
+
} = useTimelineRangeSelection({
|
|
280
|
+
scrollRef,
|
|
281
|
+
ppsRef,
|
|
282
|
+
effectiveDuration,
|
|
283
|
+
pps,
|
|
284
|
+
onSeek,
|
|
285
|
+
seekFromX,
|
|
286
|
+
autoScrollDuringDrag,
|
|
287
|
+
dragScrollRaf,
|
|
288
|
+
isDragging,
|
|
289
|
+
setShowPopover,
|
|
977
290
|
});
|
|
291
|
+
// Wire setRangeSelection into the stable ref consumed by useTimelineClipDrag
|
|
292
|
+
setRangeSelectionRef.current = setRangeSelection;
|
|
978
293
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
event.preventDefault();
|
|
987
|
-
deleteInFlightRef.current = true;
|
|
988
|
-
suppressClickRef.current = true;
|
|
294
|
+
const prevSelectedRef = useRef(selectedElementRef.current);
|
|
295
|
+
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
const prev = prevSelectedRef.current;
|
|
298
|
+
const curr = selectedElementRef.current;
|
|
299
|
+
prevSelectedRef.current = curr;
|
|
300
|
+
if (prev && !curr) {
|
|
989
301
|
setShowPopover(false);
|
|
990
302
|
setRangeSelection(null);
|
|
991
|
-
Promise.resolve(onDelete(selected)).finally(() => {
|
|
992
|
-
deleteInFlightRef.current = false;
|
|
993
|
-
requestAnimationFrame(() => {
|
|
994
|
-
suppressClickRef.current = false;
|
|
995
|
-
});
|
|
996
|
-
});
|
|
997
|
-
};
|
|
998
|
-
window.addEventListener("keydown", handleKeyDown);
|
|
999
|
-
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
const handlePointerDown = useCallback(
|
|
1003
|
-
(e: React.PointerEvent) => {
|
|
1004
|
-
if (disabledRef.current) {
|
|
1005
|
-
e.preventDefault();
|
|
1006
|
-
return;
|
|
1007
|
-
}
|
|
1008
|
-
if (e.button !== 0) return;
|
|
1009
|
-
|
|
1010
|
-
// Shift+click starts range selection — even on clips
|
|
1011
|
-
if (e.shiftKey) {
|
|
1012
|
-
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
1013
|
-
isRangeSelecting.current = true;
|
|
1014
|
-
setShowPopover(false);
|
|
1015
|
-
const rect = scrollRef.current?.getBoundingClientRect();
|
|
1016
|
-
if (rect) {
|
|
1017
|
-
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
|
|
1018
|
-
const time = Math.max(0, x / pps);
|
|
1019
|
-
rangeAnchorTime.current = time;
|
|
1020
|
-
setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
|
|
1021
|
-
}
|
|
1022
|
-
return;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
shiftClickClipRef.current = null;
|
|
1026
|
-
// Normal click on a clip — let the clip handle it
|
|
1027
|
-
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
1028
|
-
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
1029
|
-
|
|
1030
|
-
isDragging.current = true;
|
|
1031
|
-
setRangeSelection(null);
|
|
1032
|
-
setShowPopover(false);
|
|
1033
|
-
seekFromX(e.clientX);
|
|
1034
|
-
},
|
|
1035
|
-
[seekFromX, pps],
|
|
1036
|
-
);
|
|
1037
|
-
const handlePointerMove = useCallback(
|
|
1038
|
-
(e: React.PointerEvent) => {
|
|
1039
|
-
if (disabledRef.current) return;
|
|
1040
|
-
if (isRangeSelecting.current) {
|
|
1041
|
-
const rect = scrollRef.current?.getBoundingClientRect();
|
|
1042
|
-
if (rect) {
|
|
1043
|
-
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
|
|
1044
|
-
const time = Math.max(0, x / pps);
|
|
1045
|
-
setRangeSelection((prev) =>
|
|
1046
|
-
prev ? { ...prev, end: time, anchorX: e.clientX, anchorY: e.clientY } : null,
|
|
1047
|
-
);
|
|
1048
|
-
}
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
if (!isDragging.current) return;
|
|
1052
|
-
seekFromX(e.clientX);
|
|
1053
|
-
autoScrollDuringDrag(e.clientX);
|
|
1054
|
-
},
|
|
1055
|
-
[seekFromX, autoScrollDuringDrag, pps],
|
|
1056
|
-
);
|
|
1057
|
-
const handlePointerUp = useCallback(() => {
|
|
1058
|
-
if (isRangeSelecting.current) {
|
|
1059
|
-
isRangeSelecting.current = false;
|
|
1060
|
-
const pendingShiftClick = shiftClickClipRef.current;
|
|
1061
|
-
shiftClickClipRef.current = null;
|
|
1062
|
-
setRangeSelection((prev) => {
|
|
1063
|
-
if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
|
|
1064
|
-
setShowPopover(true);
|
|
1065
|
-
return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
|
|
1066
|
-
}
|
|
1067
|
-
// Show popover if range is meaningful (> 0.2s)
|
|
1068
|
-
if (prev && Math.abs(prev.end - prev.start) > 0.2) {
|
|
1069
|
-
setShowPopover(true);
|
|
1070
|
-
return prev;
|
|
1071
|
-
}
|
|
1072
|
-
return null;
|
|
1073
|
-
});
|
|
1074
|
-
return;
|
|
1075
303
|
}
|
|
1076
|
-
|
|
1077
|
-
cancelAnimationFrame(dragScrollRaf.current);
|
|
1078
|
-
}, []);
|
|
304
|
+
});
|
|
1079
305
|
|
|
1080
306
|
const { major, minor } = useMemo(
|
|
1081
307
|
() => generateTicks(effectiveDuration, pps),
|
|
@@ -1083,6 +309,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1083
309
|
);
|
|
1084
310
|
const majorTickInterval =
|
|
1085
311
|
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
|
|
312
|
+
|
|
1086
313
|
useEffect(() => {
|
|
1087
314
|
syncShortcutHintVisibility();
|
|
1088
315
|
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
|
|
@@ -1107,14 +334,11 @@ export const Timeline = memo(function Timeline({
|
|
|
1107
334
|
|
|
1108
335
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
1109
336
|
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
|
|
1110
|
-
if (disabledRef.current) return;
|
|
1111
337
|
const hasFiles = e.dataTransfer.files.length > 0;
|
|
1112
338
|
const hasAsset = Array.from(e.dataTransfer.types).includes(TIMELINE_ASSET_MIME);
|
|
1113
339
|
if (!hasFiles && !hasAsset) return;
|
|
1114
340
|
e.preventDefault();
|
|
1115
|
-
if (hasAsset)
|
|
1116
|
-
e.dataTransfer.dropEffect = "copy";
|
|
1117
|
-
}
|
|
341
|
+
if (hasAsset) e.dataTransfer.dropEffect = "copy";
|
|
1118
342
|
setIsDragOver(true);
|
|
1119
343
|
}, []);
|
|
1120
344
|
|
|
@@ -1122,283 +346,55 @@ export const Timeline = memo(function Timeline({
|
|
|
1122
346
|
(e: React.DragEvent) => {
|
|
1123
347
|
e.preventDefault();
|
|
1124
348
|
setIsDragOver(false);
|
|
1125
|
-
|
|
349
|
+
const scroll = scrollRef.current;
|
|
350
|
+
const rect = scroll?.getBoundingClientRect();
|
|
351
|
+
const dropInput = {
|
|
352
|
+
rectLeft: rect?.left ?? 0,
|
|
353
|
+
rectTop: rect?.top ?? 0,
|
|
354
|
+
scrollLeft: scroll?.scrollLeft ?? 0,
|
|
355
|
+
scrollTop: scroll?.scrollTop ?? 0,
|
|
356
|
+
pixelsPerSecond: ppsRef.current,
|
|
357
|
+
duration: durationRef.current,
|
|
358
|
+
trackHeight: TRACK_H,
|
|
359
|
+
trackOrder: trackOrderRef.current,
|
|
360
|
+
};
|
|
1126
361
|
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
? resolveTimelineAssetDrop(
|
|
1132
|
-
{
|
|
1133
|
-
rectLeft: rect.left,
|
|
1134
|
-
rectTop: rect.top,
|
|
1135
|
-
scrollLeft: scroll.scrollLeft,
|
|
1136
|
-
scrollTop: scroll.scrollTop,
|
|
1137
|
-
pixelsPerSecond: ppsRef.current,
|
|
1138
|
-
duration: durationRef.current,
|
|
1139
|
-
trackHeight: TRACK_H,
|
|
1140
|
-
trackOrder: trackOrderRef.current,
|
|
1141
|
-
},
|
|
1142
|
-
e.clientX,
|
|
1143
|
-
e.clientY,
|
|
1144
|
-
)
|
|
1145
|
-
: undefined;
|
|
1146
|
-
void onFileDrop(Array.from(e.dataTransfer.files), placement);
|
|
362
|
+
void onFileDrop(
|
|
363
|
+
Array.from(e.dataTransfer.files),
|
|
364
|
+
scroll && rect ? resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY) : undefined,
|
|
365
|
+
);
|
|
1147
366
|
return;
|
|
1148
367
|
}
|
|
1149
|
-
|
|
1150
368
|
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
|
1151
|
-
if (!assetPayload || !onAssetDrop) return;
|
|
369
|
+
if (!assetPayload || !onAssetDrop || !scroll || !rect) return;
|
|
1152
370
|
try {
|
|
1153
371
|
const parsed = JSON.parse(assetPayload) as { path?: string };
|
|
1154
|
-
if (
|
|
1155
|
-
|
|
1156
|
-
const rect = scroll?.getBoundingClientRect();
|
|
1157
|
-
if (!scroll || !rect) return;
|
|
1158
|
-
const placement = resolveTimelineAssetDrop(
|
|
1159
|
-
{
|
|
1160
|
-
rectLeft: rect.left,
|
|
1161
|
-
rectTop: rect.top,
|
|
1162
|
-
scrollLeft: scroll.scrollLeft,
|
|
1163
|
-
scrollTop: scroll.scrollTop,
|
|
1164
|
-
pixelsPerSecond: ppsRef.current,
|
|
1165
|
-
duration: durationRef.current,
|
|
1166
|
-
trackHeight: TRACK_H,
|
|
1167
|
-
trackOrder: trackOrderRef.current,
|
|
1168
|
-
},
|
|
1169
|
-
e.clientX,
|
|
1170
|
-
e.clientY,
|
|
1171
|
-
);
|
|
1172
|
-
void onAssetDrop(parsed.path, placement);
|
|
372
|
+
if (parsed.path)
|
|
373
|
+
void onAssetDrop(parsed.path, resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY));
|
|
1173
374
|
} catch {
|
|
1174
|
-
|
|
375
|
+
/* ignore malformed drag payloads */
|
|
1175
376
|
}
|
|
1176
377
|
},
|
|
1177
378
|
[onAssetDrop, onFileDrop],
|
|
1178
379
|
);
|
|
1179
380
|
|
|
1180
|
-
const handlePinchWheel = useCallback(
|
|
1181
|
-
(e: WheelEvent) => {
|
|
1182
|
-
if (disabledRef.current) return;
|
|
1183
|
-
if (!e.ctrlKey) return;
|
|
1184
|
-
const scroll = scrollRef.current;
|
|
1185
|
-
if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) {
|
|
1186
|
-
return;
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
e.preventDefault();
|
|
1190
|
-
e.stopPropagation();
|
|
1191
|
-
|
|
1192
|
-
const rect = scroll.getBoundingClientRect();
|
|
1193
|
-
const pointerX = e.clientX - rect.left;
|
|
1194
|
-
const nextZoomPercent = getPinchTimelineZoomPercent(
|
|
1195
|
-
e.deltaY,
|
|
1196
|
-
zoomModeRef.current,
|
|
1197
|
-
manualZoomPercentRef.current,
|
|
1198
|
-
);
|
|
1199
|
-
if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual") {
|
|
1200
|
-
return;
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
|
|
1204
|
-
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
1205
|
-
pointerX,
|
|
1206
|
-
currentScrollLeft: scroll.scrollLeft,
|
|
1207
|
-
gutter: GUTTER,
|
|
1208
|
-
currentPixelsPerSecond: ppsRef.current,
|
|
1209
|
-
nextPixelsPerSecond: nextPps,
|
|
1210
|
-
duration: durationRef.current,
|
|
1211
|
-
});
|
|
1212
|
-
|
|
1213
|
-
setZoomMode("manual");
|
|
1214
|
-
setManualZoomPercent(nextZoomPercent);
|
|
1215
|
-
requestAnimationFrame(() => {
|
|
1216
|
-
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
1217
|
-
scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
|
|
1218
|
-
});
|
|
1219
|
-
},
|
|
1220
|
-
[setManualZoomPercent, setZoomMode],
|
|
1221
|
-
);
|
|
1222
|
-
|
|
1223
|
-
useEffect(() => {
|
|
1224
|
-
const scroll = scrollRef.current;
|
|
1225
|
-
if (!scroll) return;
|
|
1226
|
-
scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
|
|
1227
|
-
return () => {
|
|
1228
|
-
scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
|
|
1229
|
-
};
|
|
1230
|
-
}, [handlePinchWheel, timelineReady, elements.length]);
|
|
1231
|
-
|
|
1232
381
|
if (!timelineReady || elements.length === 0) {
|
|
1233
382
|
return (
|
|
1234
|
-
<
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
}`}
|
|
1238
|
-
aria-disabled={disabled || undefined}
|
|
383
|
+
<TimelineEmptyState
|
|
384
|
+
isDragOver={isDragOver}
|
|
385
|
+
onFileDrop={!!onFileDrop}
|
|
1239
386
|
onDragOver={handleAssetDragOver}
|
|
1240
387
|
onDragLeave={() => setIsDragOver(false)}
|
|
1241
388
|
onDrop={handleAssetDrop}
|
|
1242
|
-
|
|
1243
|
-
{/* Ruler */}
|
|
1244
|
-
<div
|
|
1245
|
-
className="flex-shrink-0 border-b border-neutral-800/40 flex items-end relative"
|
|
1246
|
-
style={{ height: RULER_H, paddingLeft: GUTTER }}
|
|
1247
|
-
>
|
|
1248
|
-
{[0, 10, 20, 30, 40, 50].map((s) => (
|
|
1249
|
-
<div
|
|
1250
|
-
key={s}
|
|
1251
|
-
className="flex flex-col items-center"
|
|
1252
|
-
style={{ position: "absolute", left: GUTTER + s * 14 }}
|
|
1253
|
-
>
|
|
1254
|
-
<span className="text-[9px] text-neutral-600 font-mono tabular-nums leading-none mb-0.5">
|
|
1255
|
-
{`${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`}
|
|
1256
|
-
</span>
|
|
1257
|
-
<div className="w-px h-[5px] bg-neutral-700/40" />
|
|
1258
|
-
</div>
|
|
1259
|
-
))}
|
|
1260
|
-
</div>
|
|
1261
|
-
{/* Empty drop zone */}
|
|
1262
|
-
<div className="flex-1 flex items-center justify-center">
|
|
1263
|
-
<div
|
|
1264
|
-
className={`flex items-center gap-3 px-6 py-3 border border-dashed rounded-lg transition-colors duration-150 ${
|
|
1265
|
-
isDragOver
|
|
1266
|
-
? "border-studio-accent/60 bg-studio-accent/[0.06]"
|
|
1267
|
-
: "border-neutral-700/50"
|
|
1268
|
-
}`}
|
|
1269
|
-
>
|
|
1270
|
-
{isDragOver ? (
|
|
1271
|
-
<>
|
|
1272
|
-
<svg
|
|
1273
|
-
width="18"
|
|
1274
|
-
height="18"
|
|
1275
|
-
viewBox="0 0 24 24"
|
|
1276
|
-
fill="none"
|
|
1277
|
-
stroke="currentColor"
|
|
1278
|
-
strokeWidth="1.5"
|
|
1279
|
-
strokeLinecap="round"
|
|
1280
|
-
strokeLinejoin="round"
|
|
1281
|
-
className="text-studio-accent flex-shrink-0"
|
|
1282
|
-
>
|
|
1283
|
-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
1284
|
-
<polyline points="7 10 12 15 17 10" />
|
|
1285
|
-
<line x1="12" y1="15" x2="12" y2="3" />
|
|
1286
|
-
</svg>
|
|
1287
|
-
<span className="text-[13px] text-studio-accent">Drop media files to import</span>
|
|
1288
|
-
</>
|
|
1289
|
-
) : (
|
|
1290
|
-
<>
|
|
1291
|
-
<svg
|
|
1292
|
-
width="18"
|
|
1293
|
-
height="18"
|
|
1294
|
-
viewBox="0 0 24 24"
|
|
1295
|
-
fill="none"
|
|
1296
|
-
stroke="currentColor"
|
|
1297
|
-
strokeWidth="1.5"
|
|
1298
|
-
strokeLinecap="round"
|
|
1299
|
-
strokeLinejoin="round"
|
|
1300
|
-
className="text-neutral-600 flex-shrink-0"
|
|
1301
|
-
>
|
|
1302
|
-
<rect x="2" y="2" width="20" height="20" rx="2" />
|
|
1303
|
-
<path d="M7 2v20" />
|
|
1304
|
-
<path d="M17 2v20" />
|
|
1305
|
-
<path d="M2 7h20" />
|
|
1306
|
-
<path d="M2 17h20" />
|
|
1307
|
-
</svg>
|
|
1308
|
-
<span className="text-[13px] text-neutral-500">
|
|
1309
|
-
{onFileDrop
|
|
1310
|
-
? "Drop media here or describe your video to start"
|
|
1311
|
-
: "Describe your video to start creating"}
|
|
1312
|
-
</span>
|
|
1313
|
-
</>
|
|
1314
|
-
)}
|
|
1315
|
-
</div>
|
|
1316
|
-
</div>
|
|
1317
|
-
</div>
|
|
389
|
+
/>
|
|
1318
390
|
);
|
|
1319
391
|
}
|
|
1320
392
|
|
|
1321
|
-
const draggedElement = draggedClip?.element ?? null;
|
|
1322
|
-
const activeDraggedElement =
|
|
1323
|
-
draggedClip?.started === true && draggedElement
|
|
1324
|
-
? getRenderedTimelineElement({
|
|
1325
|
-
element: draggedElement,
|
|
1326
|
-
draggedElementId: draggedElement.key ?? draggedElement.id,
|
|
1327
|
-
previewStart: draggedClip.previewStart,
|
|
1328
|
-
previewTrack: draggedClip.previewTrack,
|
|
1329
|
-
})
|
|
1330
|
-
: null;
|
|
1331
|
-
const activeDraggedPosition =
|
|
1332
|
-
draggedClip?.started === true && activeDraggedElement && scrollRef.current
|
|
1333
|
-
? {
|
|
1334
|
-
left:
|
|
1335
|
-
draggedClip.pointerClientX -
|
|
1336
|
-
scrollRef.current.getBoundingClientRect().left +
|
|
1337
|
-
scrollRef.current.scrollLeft -
|
|
1338
|
-
draggedClip.pointerOffsetX,
|
|
1339
|
-
top:
|
|
1340
|
-
draggedClip.pointerClientY -
|
|
1341
|
-
scrollRef.current.getBoundingClientRect().top +
|
|
1342
|
-
scrollRef.current.scrollTop -
|
|
1343
|
-
draggedClip.pointerOffsetY,
|
|
1344
|
-
}
|
|
1345
|
-
: null;
|
|
1346
|
-
const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => {
|
|
1347
|
-
return (
|
|
1348
|
-
<>
|
|
1349
|
-
{renderClipOverlay?.(element)}
|
|
1350
|
-
<div
|
|
1351
|
-
className={
|
|
1352
|
-
renderClipContent
|
|
1353
|
-
? "absolute inset-0 overflow-hidden"
|
|
1354
|
-
: "flex flex-col justify-center overflow-hidden flex-1 min-w-0 px-6"
|
|
1355
|
-
}
|
|
1356
|
-
>
|
|
1357
|
-
{renderClipContent?.(element, clipStyle) ?? (
|
|
1358
|
-
<div className="flex h-full min-h-0 flex-col justify-between py-3">
|
|
1359
|
-
<div className="flex items-start">
|
|
1360
|
-
<span
|
|
1361
|
-
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] leading-none"
|
|
1362
|
-
style={{
|
|
1363
|
-
color: clipStyle.label,
|
|
1364
|
-
background: `${clipStyle.accent}26`,
|
|
1365
|
-
boxShadow: `inset 0 0 0 1px ${clipStyle.accent}33`,
|
|
1366
|
-
}}
|
|
1367
|
-
>
|
|
1368
|
-
{element.tag}
|
|
1369
|
-
</span>
|
|
1370
|
-
</div>
|
|
1371
|
-
<div className="flex items-center">
|
|
1372
|
-
<span
|
|
1373
|
-
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none"
|
|
1374
|
-
style={{
|
|
1375
|
-
color: theme.textSecondary,
|
|
1376
|
-
background: "rgba(255,255,255,0.04)",
|
|
1377
|
-
}}
|
|
1378
|
-
>
|
|
1379
|
-
{formatTime(element.start)} {"\u2192"}{" "}
|
|
1380
|
-
{formatTime(element.start + element.duration)}
|
|
1381
|
-
</span>
|
|
1382
|
-
</div>
|
|
1383
|
-
</div>
|
|
1384
|
-
)}
|
|
1385
|
-
</div>
|
|
1386
|
-
</>
|
|
1387
|
-
);
|
|
1388
|
-
};
|
|
1389
|
-
|
|
1390
393
|
return (
|
|
1391
394
|
<div
|
|
1392
395
|
ref={setContainerRef}
|
|
1393
396
|
aria-label="Timeline"
|
|
1394
|
-
|
|
1395
|
-
className={`relative border-t select-none h-full overflow-hidden transition-opacity ${
|
|
1396
|
-
disabled
|
|
1397
|
-
? "cursor-not-allowed opacity-45"
|
|
1398
|
-
: shiftHeld
|
|
1399
|
-
? "cursor-crosshair"
|
|
1400
|
-
: "cursor-default"
|
|
1401
|
-
}`}
|
|
397
|
+
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
1402
398
|
style={{
|
|
1403
399
|
touchAction: "pan-x pan-y",
|
|
1404
400
|
background: theme.shellBackground,
|
|
@@ -1416,370 +412,53 @@ export const Timeline = memo(function Timeline({
|
|
|
1416
412
|
onPointerUp={handlePointerUp}
|
|
1417
413
|
onLostPointerCapture={handlePointerUp}
|
|
1418
414
|
>
|
|
1419
|
-
<
|
|
1420
|
-
{
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
{
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
))}
|
|
1460
|
-
{major.map((t) => (
|
|
1461
|
-
<div
|
|
1462
|
-
key={`M-${t}`}
|
|
1463
|
-
className="absolute bottom-0 flex flex-col items-center"
|
|
1464
|
-
style={{ left: t * pps }}
|
|
1465
|
-
>
|
|
1466
|
-
<span
|
|
1467
|
-
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
|
|
1468
|
-
style={{ color: theme.tickText }}
|
|
1469
|
-
>
|
|
1470
|
-
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
|
|
1471
|
-
</span>
|
|
1472
|
-
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
|
|
1473
|
-
</div>
|
|
1474
|
-
))}
|
|
1475
|
-
</div>
|
|
1476
|
-
|
|
1477
|
-
{/* Tracks */}
|
|
1478
|
-
{displayTrackOrder.map((trackNum) => {
|
|
1479
|
-
const els = tracks.find(([currentTrack]) => currentTrack === trackNum)?.[1] ?? [];
|
|
1480
|
-
const ts = trackStyles.get(trackNum) ?? getStyle("");
|
|
1481
|
-
const isPendingTrack =
|
|
1482
|
-
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
1483
|
-
return (
|
|
1484
|
-
<div
|
|
1485
|
-
key={trackNum}
|
|
1486
|
-
className="relative flex"
|
|
1487
|
-
style={{
|
|
1488
|
-
height: TRACK_H,
|
|
1489
|
-
background: theme.rowBackground,
|
|
1490
|
-
borderBottom: `1px solid ${theme.rowBorder}`,
|
|
1491
|
-
}}
|
|
1492
|
-
>
|
|
1493
|
-
<div
|
|
1494
|
-
className="flex-shrink-0 flex items-center justify-center"
|
|
1495
|
-
style={{
|
|
1496
|
-
width: GUTTER,
|
|
1497
|
-
background: theme.gutterBackground,
|
|
1498
|
-
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
1499
|
-
}}
|
|
1500
|
-
>
|
|
1501
|
-
<div
|
|
1502
|
-
className="flex items-center justify-center"
|
|
1503
|
-
style={{
|
|
1504
|
-
width: 18,
|
|
1505
|
-
height: 18,
|
|
1506
|
-
borderRadius: 6,
|
|
1507
|
-
backgroundColor: ts.iconBackground,
|
|
1508
|
-
border: `1px solid ${theme.gutterBorder}`,
|
|
1509
|
-
color: "#fff",
|
|
1510
|
-
}}
|
|
1511
|
-
>
|
|
1512
|
-
{ts.icon}
|
|
1513
|
-
</div>
|
|
1514
|
-
</div>
|
|
1515
|
-
|
|
1516
|
-
{/* Clips */}
|
|
1517
|
-
<div style={{ width: trackContentWidth }} className="relative">
|
|
1518
|
-
{isPendingTrack && (
|
|
1519
|
-
<div
|
|
1520
|
-
className="absolute inset-0 flex items-center"
|
|
1521
|
-
style={{
|
|
1522
|
-
paddingLeft: 16,
|
|
1523
|
-
color: ts.label,
|
|
1524
|
-
fontSize: 11,
|
|
1525
|
-
letterSpacing: "0.08em",
|
|
1526
|
-
textTransform: "uppercase",
|
|
1527
|
-
background: `linear-gradient(90deg, ${ts.accent}14, transparent 28%)`,
|
|
1528
|
-
boxShadow: `inset 0 0 0 1px ${ts.accent}24`,
|
|
1529
|
-
}}
|
|
1530
|
-
>
|
|
1531
|
-
New track
|
|
1532
|
-
</div>
|
|
1533
|
-
)}
|
|
1534
|
-
{els.map((el, i) => {
|
|
1535
|
-
const clipStyle = getStyle(el.tag);
|
|
1536
|
-
const elementKey = el.key ?? el.id;
|
|
1537
|
-
const capabilities = getTimelineEditCapabilities(el);
|
|
1538
|
-
const isSelected = selectedElementId === elementKey;
|
|
1539
|
-
const canInspectClip = canInspectTimelineElement(el);
|
|
1540
|
-
const isInspectorActive =
|
|
1541
|
-
canInspectClip && inspectedElementId === getTimelineElementKey(el);
|
|
1542
|
-
const childCount = canInspectClip
|
|
1543
|
-
? (layerChildCounts?.get(elementKey) ?? 0)
|
|
1544
|
-
: 0;
|
|
1545
|
-
const isThumbnailActive = thumbnailedElementIds?.has(elementKey) ?? false;
|
|
1546
|
-
const thumbnailLabel = isAudioTimelineElement(el) ? "waveform" : "thumbnail";
|
|
1547
|
-
const isComposition = !!el.compositionSrc;
|
|
1548
|
-
const clipKey = `${elementKey}-${i}`;
|
|
1549
|
-
const isHovered = hoveredClip === clipKey;
|
|
1550
|
-
const hasCustomContent = !!renderClipContent;
|
|
1551
|
-
const isDragging =
|
|
1552
|
-
draggedClip?.started === true &&
|
|
1553
|
-
(draggedElement?.key ?? draggedElement?.id) === elementKey;
|
|
1554
|
-
if (isDragging) return null;
|
|
1555
|
-
const previewElement = getPreviewElement(el);
|
|
1556
|
-
|
|
1557
|
-
return (
|
|
1558
|
-
<TimelineClip
|
|
1559
|
-
key={clipKey}
|
|
1560
|
-
el={previewElement}
|
|
1561
|
-
pps={pps}
|
|
1562
|
-
clipY={CLIP_Y}
|
|
1563
|
-
isSelected={isSelected}
|
|
1564
|
-
isHovered={isHovered}
|
|
1565
|
-
isDragging={false}
|
|
1566
|
-
hasCustomContent={hasCustomContent}
|
|
1567
|
-
theme={theme}
|
|
1568
|
-
trackStyle={clipStyle}
|
|
1569
|
-
isComposition={isComposition}
|
|
1570
|
-
isInspectorActive={isInspectorActive}
|
|
1571
|
-
isThumbnailActive={isThumbnailActive}
|
|
1572
|
-
thumbnailLabel={thumbnailLabel}
|
|
1573
|
-
childCount={childCount}
|
|
1574
|
-
onHoverStart={() => setHoveredClip(clipKey)}
|
|
1575
|
-
onHoverEnd={() => setHoveredClip(null)}
|
|
1576
|
-
onInspectorClick={
|
|
1577
|
-
canInspectClip && onInspectElement
|
|
1578
|
-
? (e) => {
|
|
1579
|
-
e.stopPropagation();
|
|
1580
|
-
if (suppressClickRef.current) return;
|
|
1581
|
-
setSelectedElementId(elementKey);
|
|
1582
|
-
onSelectElement?.(el);
|
|
1583
|
-
onInspectElement(el);
|
|
1584
|
-
}
|
|
1585
|
-
: undefined
|
|
1586
|
-
}
|
|
1587
|
-
onThumbnailClick={
|
|
1588
|
-
onToggleElementThumbnail && canInspectClip
|
|
1589
|
-
? (e) => {
|
|
1590
|
-
e.stopPropagation();
|
|
1591
|
-
if (suppressClickRef.current) return;
|
|
1592
|
-
onToggleElementThumbnail(el);
|
|
1593
|
-
}
|
|
1594
|
-
: undefined
|
|
1595
|
-
}
|
|
1596
|
-
onResizeStart={(edge, e) => {
|
|
1597
|
-
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
|
1598
|
-
if (edge === "start" && !capabilities.canTrimStart) return;
|
|
1599
|
-
if (edge === "end" && !capabilities.canTrimEnd) return;
|
|
1600
|
-
e.stopPropagation();
|
|
1601
|
-
blockedClipRef.current = null;
|
|
1602
|
-
setShowPopover(false);
|
|
1603
|
-
setRangeSelection(null);
|
|
1604
|
-
setResizingClip({
|
|
1605
|
-
element: el,
|
|
1606
|
-
edge,
|
|
1607
|
-
originClientX: e.clientX,
|
|
1608
|
-
previewStart: el.start,
|
|
1609
|
-
previewDuration: el.duration,
|
|
1610
|
-
previewPlaybackStart: el.playbackStart,
|
|
1611
|
-
started: false,
|
|
1612
|
-
});
|
|
1613
|
-
}}
|
|
1614
|
-
onPointerDown={(e) => {
|
|
1615
|
-
if (e.button !== 0) return;
|
|
1616
|
-
if (e.shiftKey) {
|
|
1617
|
-
shiftClickClipRef.current = {
|
|
1618
|
-
element: el,
|
|
1619
|
-
anchorX: e.clientX,
|
|
1620
|
-
anchorY: e.clientY,
|
|
1621
|
-
};
|
|
1622
|
-
return;
|
|
1623
|
-
}
|
|
1624
|
-
const target = e.currentTarget as HTMLElement;
|
|
1625
|
-
const rect = target.getBoundingClientRect();
|
|
1626
|
-
const blockedIntent = resolveBlockedTimelineEditIntent({
|
|
1627
|
-
width: rect.width,
|
|
1628
|
-
offsetX: e.clientX - rect.left,
|
|
1629
|
-
handleWidth: CLIP_HANDLE_W,
|
|
1630
|
-
capabilities,
|
|
1631
|
-
});
|
|
1632
|
-
if (
|
|
1633
|
-
blockedIntent &&
|
|
1634
|
-
((blockedIntent === "move" && onMoveElement) ||
|
|
1635
|
-
(blockedIntent !== "move" && onResizeElement))
|
|
1636
|
-
) {
|
|
1637
|
-
blockedClipRef.current = {
|
|
1638
|
-
element: el,
|
|
1639
|
-
intent: blockedIntent,
|
|
1640
|
-
originClientX: e.clientX,
|
|
1641
|
-
originClientY: e.clientY,
|
|
1642
|
-
started: false,
|
|
1643
|
-
};
|
|
1644
|
-
return;
|
|
1645
|
-
}
|
|
1646
|
-
if (!onMoveElement || !capabilities.canMove) return;
|
|
1647
|
-
blockedClipRef.current = null;
|
|
1648
|
-
setShowPopover(false);
|
|
1649
|
-
setRangeSelection(null);
|
|
1650
|
-
setDraggedClip({
|
|
1651
|
-
element: el,
|
|
1652
|
-
originClientX: e.clientX,
|
|
1653
|
-
originClientY: e.clientY,
|
|
1654
|
-
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
1655
|
-
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
|
1656
|
-
pointerClientX: e.clientX,
|
|
1657
|
-
pointerClientY: e.clientY,
|
|
1658
|
-
pointerOffsetX: e.clientX - rect.left,
|
|
1659
|
-
pointerOffsetY: e.clientY - rect.top,
|
|
1660
|
-
previewStart: el.start,
|
|
1661
|
-
previewTrack: el.track,
|
|
1662
|
-
started: false,
|
|
1663
|
-
});
|
|
1664
|
-
}}
|
|
1665
|
-
onClick={(e) => {
|
|
1666
|
-
e.stopPropagation();
|
|
1667
|
-
if (suppressClickRef.current) return;
|
|
1668
|
-
const nextElement = isSelected ? null : el;
|
|
1669
|
-
setSelectedElementId(nextElement ? elementKey : null);
|
|
1670
|
-
onSelectElement?.(nextElement);
|
|
1671
|
-
}}
|
|
1672
|
-
onDoubleClick={(e) => {
|
|
1673
|
-
e.stopPropagation();
|
|
1674
|
-
if (suppressClickRef.current) return;
|
|
1675
|
-
if (isComposition && onDrillDown) onDrillDown(el);
|
|
1676
|
-
}}
|
|
1677
|
-
>
|
|
1678
|
-
{renderClipChildren(previewElement, clipStyle)}
|
|
1679
|
-
</TimelineClip>
|
|
1680
|
-
);
|
|
1681
|
-
})}
|
|
1682
|
-
</div>
|
|
1683
|
-
</div>
|
|
1684
|
-
);
|
|
1685
|
-
})}
|
|
1686
|
-
|
|
1687
|
-
{activeDraggedElement && activeDraggedPosition && (
|
|
1688
|
-
<div
|
|
1689
|
-
className="absolute pointer-events-none"
|
|
1690
|
-
style={{
|
|
1691
|
-
top: activeDraggedPosition.top,
|
|
1692
|
-
left: activeDraggedPosition.left,
|
|
1693
|
-
width: Math.max(activeDraggedElement.duration * pps, 4),
|
|
1694
|
-
height: TRACK_H - CLIP_Y * 2,
|
|
1695
|
-
zIndex: 40,
|
|
1696
|
-
}}
|
|
1697
|
-
>
|
|
1698
|
-
<TimelineClip
|
|
1699
|
-
el={{ ...activeDraggedElement, start: 0 }}
|
|
1700
|
-
pps={pps}
|
|
1701
|
-
clipY={0}
|
|
1702
|
-
isSelected={
|
|
1703
|
-
selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)
|
|
1704
|
-
}
|
|
1705
|
-
isHovered={false}
|
|
1706
|
-
isDragging={true}
|
|
1707
|
-
hasCustomContent={!!renderClipContent}
|
|
1708
|
-
theme={theme}
|
|
1709
|
-
trackStyle={getStyle(activeDraggedElement.tag)}
|
|
1710
|
-
isComposition={!!activeDraggedElement.compositionSrc}
|
|
1711
|
-
onHoverStart={() => {}}
|
|
1712
|
-
onHoverEnd={() => {}}
|
|
1713
|
-
onResizeStart={() => {}}
|
|
1714
|
-
onClick={() => {}}
|
|
1715
|
-
onDoubleClick={() => {}}
|
|
1716
|
-
>
|
|
1717
|
-
{renderClipChildren(activeDraggedElement, getStyle(activeDraggedElement.tag))}
|
|
1718
|
-
</TimelineClip>
|
|
1719
|
-
</div>
|
|
1720
|
-
)}
|
|
1721
|
-
|
|
1722
|
-
{/* Range selection highlight */}
|
|
1723
|
-
{rangeSelection && (
|
|
1724
|
-
<div
|
|
1725
|
-
className="absolute pointer-events-none"
|
|
1726
|
-
style={{
|
|
1727
|
-
left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
|
|
1728
|
-
width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
|
|
1729
|
-
top: RULER_H,
|
|
1730
|
-
bottom: 0,
|
|
1731
|
-
backgroundColor: "rgba(59, 130, 246, 0.12)",
|
|
1732
|
-
borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
|
|
1733
|
-
borderRight: "1px solid rgba(59, 130, 246, 0.4)",
|
|
1734
|
-
zIndex: 50,
|
|
1735
|
-
}}
|
|
1736
|
-
/>
|
|
1737
|
-
)}
|
|
1738
|
-
|
|
1739
|
-
{/* Playhead — z-[100] to stay above all clips (which use z-1 to z-10) */}
|
|
1740
|
-
<div
|
|
1741
|
-
ref={playheadRef}
|
|
1742
|
-
className="absolute top-0 bottom-0 pointer-events-none"
|
|
1743
|
-
style={{ left: `${GUTTER}px`, zIndex: 100 }}
|
|
1744
|
-
>
|
|
1745
|
-
<div
|
|
1746
|
-
className="absolute top-0 bottom-0"
|
|
1747
|
-
style={{
|
|
1748
|
-
left: "50%",
|
|
1749
|
-
width: 2,
|
|
1750
|
-
marginLeft: -1,
|
|
1751
|
-
background: "var(--hf-accent, #3CE6AC)",
|
|
1752
|
-
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
|
|
1753
|
-
}}
|
|
1754
|
-
/>
|
|
1755
|
-
<div
|
|
1756
|
-
className="absolute"
|
|
1757
|
-
style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
|
|
1758
|
-
>
|
|
1759
|
-
<div
|
|
1760
|
-
style={{
|
|
1761
|
-
width: 0,
|
|
1762
|
-
height: 0,
|
|
1763
|
-
borderLeft: "6px solid transparent",
|
|
1764
|
-
borderRight: "6px solid transparent",
|
|
1765
|
-
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
|
|
1766
|
-
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
|
1767
|
-
}}
|
|
1768
|
-
/>
|
|
1769
|
-
</div>
|
|
1770
|
-
</div>
|
|
1771
|
-
</div>
|
|
415
|
+
<TimelineCanvas
|
|
416
|
+
major={major}
|
|
417
|
+
minor={minor}
|
|
418
|
+
pps={pps}
|
|
419
|
+
trackContentWidth={trackContentWidth}
|
|
420
|
+
totalH={totalH}
|
|
421
|
+
effectiveDuration={effectiveDuration}
|
|
422
|
+
majorTickInterval={majorTickInterval}
|
|
423
|
+
shiftHeld={shiftHeld}
|
|
424
|
+
rangeSelection={rangeSelection}
|
|
425
|
+
theme={theme}
|
|
426
|
+
displayTrackOrder={displayTrackOrder}
|
|
427
|
+
trackOrder={trackOrder}
|
|
428
|
+
tracks={tracks}
|
|
429
|
+
trackStyles={trackStyles}
|
|
430
|
+
selectedElementId={selectedElementId}
|
|
431
|
+
hoveredClip={hoveredClip}
|
|
432
|
+
draggedClip={draggedClip}
|
|
433
|
+
resizingClip={resizingClip}
|
|
434
|
+
blockedClipRef={blockedClipRef}
|
|
435
|
+
suppressClickRef={suppressClickRef}
|
|
436
|
+
scrollRef={scrollRef}
|
|
437
|
+
renderClipContent={renderClipContent}
|
|
438
|
+
renderClipOverlay={renderClipOverlay}
|
|
439
|
+
playheadRef={playheadRef}
|
|
440
|
+
onResizeElement={onResizeElement}
|
|
441
|
+
onMoveElement={onMoveElement}
|
|
442
|
+
onDrillDown={onDrillDown}
|
|
443
|
+
onSelectElement={onSelectElement}
|
|
444
|
+
setHoveredClip={setHoveredClip}
|
|
445
|
+
setShowPopover={setShowPopover}
|
|
446
|
+
setRangeSelection={setRangeSelection}
|
|
447
|
+
setResizingClip={setResizingClip}
|
|
448
|
+
setDraggedClip={setDraggedClip}
|
|
449
|
+
setSelectedElementId={setSelectedElementId}
|
|
450
|
+
syncClipDragAutoScroll={syncClipDragAutoScroll}
|
|
451
|
+
shiftClickClipRef={shiftClickClipRef}
|
|
452
|
+
getPreviewElement={getPreviewElement}
|
|
453
|
+
getTrackStyle={getTrackStyle}
|
|
454
|
+
/>
|
|
1772
455
|
</div>
|
|
1773
456
|
|
|
1774
|
-
{/* Keyboard shortcut hint */}
|
|
1775
457
|
{showShortcutHint && !showPopover && !rangeSelection && (
|
|
1776
458
|
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
|
1777
459
|
<div
|
|
1778
460
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
|
1779
|
-
style={{
|
|
1780
|
-
background: "rgba(17,23,35,0.84)",
|
|
1781
|
-
borderColor: theme.gutterBorder,
|
|
1782
|
-
}}
|
|
461
|
+
style={{ background: "rgba(17,23,35,0.84)", borderColor: theme.gutterBorder }}
|
|
1783
462
|
>
|
|
1784
463
|
<kbd
|
|
1785
464
|
className="text-[9px] font-mono px-1 py-0.5 rounded"
|
|
@@ -1794,7 +473,6 @@ export const Timeline = memo(function Timeline({
|
|
|
1794
473
|
</div>
|
|
1795
474
|
)}
|
|
1796
475
|
|
|
1797
|
-
{/* Edit range popover */}
|
|
1798
476
|
{showPopover && rangeSelection && (
|
|
1799
477
|
<EditPopover
|
|
1800
478
|
rangeStart={rangeSelection.start}
|