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