@hyperframes/studio 0.4.12 → 0.4.13-alpha.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-BOs_kypk.js +198 -0
- package/dist/assets/index-BKkR67xb.css +1 -0
- package/dist/assets/index-rN5doSq1.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +289 -11
- package/src/components/nle/NLELayout.tsx +24 -7
- package/src/components/nle/NLEPreview.test.ts +32 -0
- package/src/components/nle/NLEPreview.tsx +12 -1
- package/src/player/components/CompositionThumbnail.tsx +94 -17
- package/src/player/components/EditModal.tsx +48 -29
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/PlayerControls.test.ts +20 -0
- package/src/player/components/PlayerControls.tsx +12 -1
- package/src/player/components/Timeline.test.ts +44 -1
- package/src/player/components/Timeline.tsx +686 -169
- package/src/player/components/TimelineClip.tsx +112 -16
- package/src/player/components/timelineEditing.test.ts +310 -0
- package/src/player/components/timelineEditing.ts +213 -0
- package/src/player/components/timelineTheme.test.ts +56 -0
- package/src/player/components/timelineTheme.ts +141 -0
- package/src/player/components/timelineZoom.test.ts +62 -0
- package/src/player/components/timelineZoom.ts +38 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +96 -0
- package/src/player/hooks/useTimelinePlayer.ts +313 -59
- package/src/player/store/playerStore.test.ts +30 -12
- package/src/player/store/playerStore.ts +23 -9
- package/src/types/hyperframes-player.d.ts +1 -0
- package/src/utils/sourcePatcher.test.ts +84 -0
- package/src/utils/sourcePatcher.ts +143 -0
- package/dist/assets/hyperframes-player-5iD9BZnx.js +0 -198
- package/dist/assets/index-CVDXfFQ6.js +0 -93
- package/dist/assets/index-jmDaI2F7.css +0 -1
|
@@ -1,9 +1,27 @@
|
|
|
1
|
-
import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
|
|
2
|
-
import {
|
|
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";
|
|
3
8
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
9
|
import { formatTime } from "../lib/time";
|
|
5
10
|
import { TimelineClip } from "./TimelineClip";
|
|
6
11
|
import { EditPopover } from "./EditModal";
|
|
12
|
+
import {
|
|
13
|
+
resolveTimelineAutoScroll,
|
|
14
|
+
resolveTimelineMove,
|
|
15
|
+
resolveTimelineResize,
|
|
16
|
+
} from "./timelineEditing";
|
|
17
|
+
import {
|
|
18
|
+
defaultTimelineTheme,
|
|
19
|
+
getRenderedTimelineElement,
|
|
20
|
+
getTimelineTrackStyle,
|
|
21
|
+
type TimelineTrackStyle,
|
|
22
|
+
type TimelineTheme,
|
|
23
|
+
} from "./timelineTheme";
|
|
24
|
+
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
7
25
|
|
|
8
26
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
|
9
27
|
const GUTTER = 32;
|
|
@@ -11,17 +29,7 @@ const TRACK_H = 72;
|
|
|
11
29
|
const RULER_H = 24;
|
|
12
30
|
const CLIP_Y = 3; // vertical inset inside track
|
|
13
31
|
|
|
14
|
-
|
|
15
|
-
interface TrackStyle {
|
|
16
|
-
/** Clip solid background */
|
|
17
|
-
clip: string;
|
|
18
|
-
/** Dark text color for label on clip */
|
|
19
|
-
label: string;
|
|
20
|
-
/** Track row tint (very subtle) */
|
|
21
|
-
row: string;
|
|
22
|
-
/** Gutter icon circle background */
|
|
23
|
-
gutter: string;
|
|
24
|
-
/** SVG icon paths (viewBox 0 0 24 24) */
|
|
32
|
+
interface TrackVisualStyle extends TimelineTrackStyle {
|
|
25
33
|
icon: ReactNode;
|
|
26
34
|
}
|
|
27
35
|
|
|
@@ -46,84 +54,29 @@ const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
|
|
|
46
54
|
const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
|
|
47
55
|
const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
|
|
48
56
|
|
|
49
|
-
const
|
|
50
|
-
video:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
label: "#013A4B",
|
|
60
|
-
row: "rgba(0,196,255,0.04)",
|
|
61
|
-
gutter: "#00C4FF",
|
|
62
|
-
icon: IconMusic,
|
|
63
|
-
},
|
|
64
|
-
img: {
|
|
65
|
-
clip: "#8B5CF6",
|
|
66
|
-
label: "#EDE9FE",
|
|
67
|
-
row: "rgba(139,92,246,0.04)",
|
|
68
|
-
gutter: "#8B5CF6",
|
|
69
|
-
icon: IconImage,
|
|
70
|
-
},
|
|
71
|
-
div: {
|
|
72
|
-
clip: "#68B200",
|
|
73
|
-
label: "#1A2B03",
|
|
74
|
-
row: "rgba(104,178,0,0.04)",
|
|
75
|
-
gutter: "#68B200",
|
|
76
|
-
icon: IconComposition,
|
|
77
|
-
},
|
|
78
|
-
span: {
|
|
79
|
-
clip: "#F3A6FF",
|
|
80
|
-
label: "#8D00A3",
|
|
81
|
-
row: "rgba(243,166,255,0.04)",
|
|
82
|
-
gutter: "#F3A6FF",
|
|
83
|
-
icon: IconCaptions,
|
|
84
|
-
},
|
|
85
|
-
p: {
|
|
86
|
-
clip: "#35C838",
|
|
87
|
-
label: "#024A03",
|
|
88
|
-
row: "rgba(53,200,56,0.04)",
|
|
89
|
-
gutter: "#35C838",
|
|
90
|
-
icon: IconText,
|
|
91
|
-
},
|
|
92
|
-
h1: {
|
|
93
|
-
clip: "#35C838",
|
|
94
|
-
label: "#024A03",
|
|
95
|
-
row: "rgba(53,200,56,0.04)",
|
|
96
|
-
gutter: "#35C838",
|
|
97
|
-
icon: IconText,
|
|
98
|
-
},
|
|
99
|
-
section: {
|
|
100
|
-
clip: "#68B200",
|
|
101
|
-
label: "#1A2B03",
|
|
102
|
-
row: "rgba(104,178,0,0.04)",
|
|
103
|
-
gutter: "#68B200",
|
|
104
|
-
icon: IconComposition,
|
|
105
|
-
},
|
|
106
|
-
sfx: {
|
|
107
|
-
clip: "#FF8C42",
|
|
108
|
-
label: "#512000",
|
|
109
|
-
row: "rgba(255,140,66,0.04)",
|
|
110
|
-
gutter: "#FF8C42",
|
|
111
|
-
icon: IconAudio,
|
|
112
|
-
},
|
|
57
|
+
const ICONS: Record<string, ReactNode> = {
|
|
58
|
+
video: IconImage,
|
|
59
|
+
audio: IconMusic,
|
|
60
|
+
img: IconImage,
|
|
61
|
+
div: IconComposition,
|
|
62
|
+
span: IconCaptions,
|
|
63
|
+
p: IconText,
|
|
64
|
+
h1: IconText,
|
|
65
|
+
section: IconComposition,
|
|
66
|
+
sfx: IconAudio,
|
|
113
67
|
};
|
|
114
68
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return STYLES[t] ?? DEFAULT;
|
|
69
|
+
function getStyle(tag: string): TrackVisualStyle {
|
|
70
|
+
const trackStyle = getTimelineTrackStyle(tag);
|
|
71
|
+
const normalized = tag.toLowerCase();
|
|
72
|
+
const icon =
|
|
73
|
+
normalized.startsWith("h") && normalized.length === 2 && "123456".includes(normalized[1] ?? "")
|
|
74
|
+
? ICONS.h1
|
|
75
|
+
: (ICONS[normalized] ?? IconComposition);
|
|
76
|
+
return {
|
|
77
|
+
...trackStyle,
|
|
78
|
+
icon,
|
|
79
|
+
};
|
|
127
80
|
}
|
|
128
81
|
|
|
129
82
|
/* ── Tick Generation ────────────────────────────────────────────── */
|
|
@@ -152,6 +105,30 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
|
|
|
152
105
|
return { major, minor };
|
|
153
106
|
}
|
|
154
107
|
|
|
108
|
+
export function shouldAutoScrollTimeline(
|
|
109
|
+
zoomMode: ZoomMode,
|
|
110
|
+
scrollWidth: number,
|
|
111
|
+
clientWidth: number,
|
|
112
|
+
): boolean {
|
|
113
|
+
if (zoomMode === "fit") return false;
|
|
114
|
+
if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false;
|
|
115
|
+
return scrollWidth - clientWidth > 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function getTimelineScrollLeftForZoomTransition(
|
|
119
|
+
previousZoomMode: ZoomMode | null,
|
|
120
|
+
nextZoomMode: ZoomMode,
|
|
121
|
+
currentScrollLeft: number,
|
|
122
|
+
): number {
|
|
123
|
+
if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
|
|
124
|
+
return currentScrollLeft;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
|
128
|
+
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
|
|
129
|
+
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
|
|
130
|
+
}
|
|
131
|
+
|
|
155
132
|
/* ── Component ──────────────────────────────────────────────────── */
|
|
156
133
|
interface TimelineProps {
|
|
157
134
|
/** Called when user seeks via ruler/track click or playhead drag */
|
|
@@ -167,6 +144,44 @@ interface TimelineProps {
|
|
|
167
144
|
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
|
|
168
145
|
/** Called when files are dropped onto the empty timeline */
|
|
169
146
|
onFileDrop?: (files: File[]) => void;
|
|
147
|
+
/** Persist a clip move back into source HTML */
|
|
148
|
+
onMoveElement?: (
|
|
149
|
+
element: import("../store/playerStore").TimelineElement,
|
|
150
|
+
updates: Pick<import("../store/playerStore").TimelineElement, "start" | "track">,
|
|
151
|
+
) => Promise<void> | void;
|
|
152
|
+
onResizeElement?: (
|
|
153
|
+
element: import("../store/playerStore").TimelineElement,
|
|
154
|
+
updates: Pick<
|
|
155
|
+
import("../store/playerStore").TimelineElement,
|
|
156
|
+
"start" | "duration" | "playbackStart"
|
|
157
|
+
>,
|
|
158
|
+
) => Promise<void> | void;
|
|
159
|
+
theme?: Partial<TimelineTheme>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface DraggedClipState {
|
|
163
|
+
element: TimelineElement;
|
|
164
|
+
originClientX: number;
|
|
165
|
+
originClientY: number;
|
|
166
|
+
originScrollLeft: number;
|
|
167
|
+
originScrollTop: number;
|
|
168
|
+
pointerClientX: number;
|
|
169
|
+
pointerClientY: number;
|
|
170
|
+
pointerOffsetX: number;
|
|
171
|
+
pointerOffsetY: number;
|
|
172
|
+
previewStart: number;
|
|
173
|
+
previewTrack: number;
|
|
174
|
+
started: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface ResizingClipState {
|
|
178
|
+
element: TimelineElement;
|
|
179
|
+
edge: "start" | "end";
|
|
180
|
+
originClientX: number;
|
|
181
|
+
previewStart: number;
|
|
182
|
+
previewDuration: number;
|
|
183
|
+
previewPlaybackStart?: number;
|
|
184
|
+
started: boolean;
|
|
170
185
|
}
|
|
171
186
|
|
|
172
187
|
export const Timeline = memo(function Timeline({
|
|
@@ -175,14 +190,20 @@ export const Timeline = memo(function Timeline({
|
|
|
175
190
|
renderClipContent,
|
|
176
191
|
renderClipOverlay,
|
|
177
192
|
onFileDrop,
|
|
193
|
+
onMoveElement,
|
|
194
|
+
onResizeElement,
|
|
195
|
+
theme: themeOverrides,
|
|
178
196
|
}: TimelineProps = {}) {
|
|
197
|
+
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
179
198
|
const elements = usePlayerStore((s) => s.elements);
|
|
180
199
|
const duration = usePlayerStore((s) => s.duration);
|
|
181
200
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
182
201
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
183
202
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
203
|
+
const updateElement = usePlayerStore((s) => s.updateElement);
|
|
204
|
+
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
184
205
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
185
|
-
const
|
|
206
|
+
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
186
207
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
187
208
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
188
209
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
@@ -211,6 +232,17 @@ export const Timeline = memo(function Timeline({
|
|
|
211
232
|
anchorX: number;
|
|
212
233
|
anchorY: number;
|
|
213
234
|
} | null>(null);
|
|
235
|
+
const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
|
|
236
|
+
const draggedClipRef = useRef<DraggedClipState | null>(null);
|
|
237
|
+
draggedClipRef.current = draggedClip;
|
|
238
|
+
const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
|
|
239
|
+
const resizingClipRef = useRef<ResizingClipState | null>(null);
|
|
240
|
+
resizingClipRef.current = resizingClip;
|
|
241
|
+
const onMoveElementRef = useRef(onMoveElement);
|
|
242
|
+
onMoveElementRef.current = onMoveElement;
|
|
243
|
+
const onResizeElementRef = useRef(onResizeElement);
|
|
244
|
+
onResizeElementRef.current = onResizeElement;
|
|
245
|
+
const suppressClickRef = useRef(false);
|
|
214
246
|
const [showPopover, setShowPopover] = useState(false);
|
|
215
247
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
216
248
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
@@ -249,30 +281,91 @@ export const Timeline = memo(function Timeline({
|
|
|
249
281
|
return Number.isFinite(result) ? result : safeDur;
|
|
250
282
|
}, [elements, duration]);
|
|
251
283
|
|
|
284
|
+
const tracks = useMemo(() => {
|
|
285
|
+
const map = new Map<number, typeof elements>();
|
|
286
|
+
for (const el of elements) {
|
|
287
|
+
const list = map.get(el.track) ?? [];
|
|
288
|
+
list.push(el);
|
|
289
|
+
map.set(el.track, list);
|
|
290
|
+
}
|
|
291
|
+
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
|
292
|
+
}, [elements]);
|
|
293
|
+
|
|
294
|
+
const trackStyles = useMemo(() => {
|
|
295
|
+
const map = new Map<number, TrackVisualStyle>();
|
|
296
|
+
for (const [trackNum, els] of tracks) {
|
|
297
|
+
map.set(trackNum, getStyle(els[0]?.tag ?? ""));
|
|
298
|
+
}
|
|
299
|
+
return map;
|
|
300
|
+
}, [tracks]);
|
|
301
|
+
|
|
302
|
+
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
|
|
303
|
+
const trackOrderRef = useRef(trackOrder);
|
|
304
|
+
trackOrderRef.current = trackOrder;
|
|
305
|
+
const displayTrackOrder = useMemo(() => {
|
|
306
|
+
if (
|
|
307
|
+
!draggedClip?.started ||
|
|
308
|
+
trackOrder.length === 0 ||
|
|
309
|
+
trackOrder.includes(draggedClip.previewTrack)
|
|
310
|
+
) {
|
|
311
|
+
return trackOrder;
|
|
312
|
+
}
|
|
313
|
+
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
314
|
+
}, [draggedClip, trackOrder]);
|
|
315
|
+
|
|
252
316
|
// Calculate effective pixels per second
|
|
253
317
|
// In fit mode, use clientWidth (excludes scrollbar) with a small padding
|
|
254
318
|
const fitPps =
|
|
255
319
|
viewportWidth > GUTTER && effectiveDuration > 0
|
|
256
320
|
? (viewportWidth - GUTTER - 2) / effectiveDuration
|
|
257
321
|
: 100;
|
|
258
|
-
const pps =
|
|
322
|
+
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
|
|
259
323
|
const trackContentWidth = Math.max(0, effectiveDuration * pps);
|
|
324
|
+
const zoomModeRef = useRef(zoomMode);
|
|
325
|
+
zoomModeRef.current = zoomMode;
|
|
326
|
+
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
|
260
327
|
|
|
261
328
|
const durationRef = useRef(effectiveDuration);
|
|
262
329
|
durationRef.current = effectiveDuration;
|
|
263
330
|
const ppsRef = useRef(pps);
|
|
264
331
|
ppsRef.current = pps;
|
|
332
|
+
const syncPlayheadPosition = useCallback((time: number) => {
|
|
333
|
+
if (!playheadRef.current || durationRef.current <= 0) return;
|
|
334
|
+
playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current)}px`;
|
|
335
|
+
}, []);
|
|
336
|
+
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
syncPlayheadPosition(currentTime);
|
|
339
|
+
}, [currentTime, pps, syncPlayheadPosition]);
|
|
340
|
+
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
const scroll = scrollRef.current;
|
|
343
|
+
if (!scroll) {
|
|
344
|
+
previousZoomModeRef.current = zoomMode;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
scroll.scrollLeft = getTimelineScrollLeftForZoomTransition(
|
|
348
|
+
previousZoomModeRef.current,
|
|
349
|
+
zoomMode,
|
|
350
|
+
scroll.scrollLeft,
|
|
351
|
+
);
|
|
352
|
+
previousZoomModeRef.current = zoomMode;
|
|
353
|
+
}, [zoomMode]);
|
|
354
|
+
|
|
265
355
|
useMountEffect(() => {
|
|
266
356
|
const unsub = liveTime.subscribe((t) => {
|
|
267
357
|
const dur = durationRef.current;
|
|
268
358
|
if (!playheadRef.current || dur <= 0) return;
|
|
269
|
-
const
|
|
270
|
-
playheadRef.current.style.left = `${
|
|
359
|
+
const playheadX = getTimelinePlayheadLeft(t, ppsRef.current);
|
|
360
|
+
playheadRef.current.style.left = `${playheadX}px`;
|
|
271
361
|
|
|
272
362
|
// Auto-scroll to follow playhead during playback or seeking
|
|
273
363
|
const scroll = scrollRef.current;
|
|
274
|
-
if (
|
|
275
|
-
|
|
364
|
+
if (
|
|
365
|
+
scroll &&
|
|
366
|
+
!isDragging.current &&
|
|
367
|
+
shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth)
|
|
368
|
+
) {
|
|
276
369
|
const visibleRight = scroll.scrollLeft + scroll.clientWidth;
|
|
277
370
|
const visibleLeft = scroll.scrollLeft;
|
|
278
371
|
const edgeMargin = scroll.clientWidth * 0.12;
|
|
@@ -290,6 +383,106 @@ export const Timeline = memo(function Timeline({
|
|
|
290
383
|
});
|
|
291
384
|
|
|
292
385
|
const dragScrollRaf = useRef(0);
|
|
386
|
+
const clipDragScrollRaf = useRef(0);
|
|
387
|
+
const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
|
|
388
|
+
|
|
389
|
+
const updateDraggedClipPreview = useCallback(
|
|
390
|
+
(drag: DraggedClipState, clientX: number, clientY: number) => {
|
|
391
|
+
const scroll = scrollRef.current;
|
|
392
|
+
const nextMove = resolveTimelineMove(
|
|
393
|
+
{
|
|
394
|
+
start: drag.element.start,
|
|
395
|
+
track: drag.element.track,
|
|
396
|
+
duration: drag.element.duration,
|
|
397
|
+
originClientX: drag.originClientX,
|
|
398
|
+
originClientY: drag.originClientY,
|
|
399
|
+
originScrollLeft: drag.originScrollLeft,
|
|
400
|
+
originScrollTop: drag.originScrollTop,
|
|
401
|
+
currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
|
|
402
|
+
currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
|
|
403
|
+
pixelsPerSecond: ppsRef.current,
|
|
404
|
+
trackHeight: TRACK_H,
|
|
405
|
+
maxStart: Math.max(0, durationRef.current - drag.element.duration),
|
|
406
|
+
trackOrder: trackOrderRef.current,
|
|
407
|
+
},
|
|
408
|
+
clientX,
|
|
409
|
+
clientY,
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
...drag,
|
|
414
|
+
started: true,
|
|
415
|
+
pointerClientX: clientX,
|
|
416
|
+
pointerClientY: clientY,
|
|
417
|
+
previewStart: nextMove.start,
|
|
418
|
+
previewTrack: nextMove.track,
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
[],
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const stopClipDragAutoScroll = useCallback(() => {
|
|
425
|
+
clipDragPointerRef.current = null;
|
|
426
|
+
if (clipDragScrollRaf.current) {
|
|
427
|
+
cancelAnimationFrame(clipDragScrollRaf.current);
|
|
428
|
+
clipDragScrollRaf.current = 0;
|
|
429
|
+
}
|
|
430
|
+
}, []);
|
|
431
|
+
|
|
432
|
+
const stepClipDragAutoScroll = useCallback(() => {
|
|
433
|
+
clipDragScrollRaf.current = 0;
|
|
434
|
+
const drag = draggedClipRef.current;
|
|
435
|
+
const pointer = clipDragPointerRef.current;
|
|
436
|
+
const scroll = scrollRef.current;
|
|
437
|
+
if (!drag || !pointer || !scroll) return;
|
|
438
|
+
|
|
439
|
+
const rect = scroll.getBoundingClientRect();
|
|
440
|
+
const delta = resolveTimelineAutoScroll(rect, pointer.clientX, pointer.clientY);
|
|
441
|
+
if (delta.x === 0 && delta.y === 0) return;
|
|
442
|
+
|
|
443
|
+
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
444
|
+
const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
|
|
445
|
+
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
|
|
446
|
+
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
|
|
447
|
+
const didScroll = nextScrollLeft !== scroll.scrollLeft || nextScrollTop !== scroll.scrollTop;
|
|
448
|
+
|
|
449
|
+
if (!didScroll) return;
|
|
450
|
+
|
|
451
|
+
scroll.scrollLeft = nextScrollLeft;
|
|
452
|
+
scroll.scrollTop = nextScrollTop;
|
|
453
|
+
setDraggedClip((prev) =>
|
|
454
|
+
prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
|
|
458
|
+
}, [updateDraggedClipPreview]);
|
|
459
|
+
|
|
460
|
+
const syncClipDragAutoScroll = useCallback(
|
|
461
|
+
(clientX: number, clientY: number) => {
|
|
462
|
+
clipDragPointerRef.current = { clientX, clientY };
|
|
463
|
+
const scroll = scrollRef.current;
|
|
464
|
+
if (!scroll) return;
|
|
465
|
+
const rect = scroll.getBoundingClientRect();
|
|
466
|
+
const delta = resolveTimelineAutoScroll(rect, clientX, clientY);
|
|
467
|
+
if (delta.x === 0 && delta.y === 0) {
|
|
468
|
+
if (clipDragScrollRaf.current) {
|
|
469
|
+
cancelAnimationFrame(clipDragScrollRaf.current);
|
|
470
|
+
clipDragScrollRaf.current = 0;
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (!clipDragScrollRaf.current) {
|
|
475
|
+
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
[stepClipDragAutoScroll],
|
|
479
|
+
);
|
|
480
|
+
const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview);
|
|
481
|
+
updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
|
|
482
|
+
const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
|
|
483
|
+
syncClipDragAutoScrollRef.current = syncClipDragAutoScroll;
|
|
484
|
+
const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
|
|
485
|
+
stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
|
|
293
486
|
|
|
294
487
|
const seekFromX = useCallback(
|
|
295
488
|
(clientX: number) => {
|
|
@@ -311,7 +504,13 @@ export const Timeline = memo(function Timeline({
|
|
|
311
504
|
(clientX: number) => {
|
|
312
505
|
cancelAnimationFrame(dragScrollRaf.current);
|
|
313
506
|
const el = scrollRef.current;
|
|
314
|
-
if (
|
|
507
|
+
if (
|
|
508
|
+
!el ||
|
|
509
|
+
!isDragging.current ||
|
|
510
|
+
!shouldAutoScrollTimeline(zoomModeRef.current, el.scrollWidth, el.clientWidth)
|
|
511
|
+
) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
315
514
|
const rect = el.getBoundingClientRect();
|
|
316
515
|
const edgeZone = 40;
|
|
317
516
|
const maxSpeed = 12;
|
|
@@ -336,6 +535,158 @@ export const Timeline = memo(function Timeline({
|
|
|
336
535
|
[seekFromX],
|
|
337
536
|
);
|
|
338
537
|
|
|
538
|
+
useMountEffect(() => {
|
|
539
|
+
const clearSuppressedClick = () => {
|
|
540
|
+
requestAnimationFrame(() => {
|
|
541
|
+
suppressClickRef.current = false;
|
|
542
|
+
});
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const handleWindowPointerMove = (e: PointerEvent) => {
|
|
546
|
+
const drag = draggedClipRef.current;
|
|
547
|
+
const resize = resizingClipRef.current;
|
|
548
|
+
if (resize) {
|
|
549
|
+
const distance = Math.abs(e.clientX - resize.originClientX);
|
|
550
|
+
if (!resize.started && distance < 2) return;
|
|
551
|
+
|
|
552
|
+
setShowPopover(false);
|
|
553
|
+
setRangeSelection(null);
|
|
554
|
+
|
|
555
|
+
const sourceRemaining =
|
|
556
|
+
resize.element.sourceDuration != null
|
|
557
|
+
? Math.max(
|
|
558
|
+
0,
|
|
559
|
+
(resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) /
|
|
560
|
+
Math.max(resize.element.playbackRate ?? 1, 0.1),
|
|
561
|
+
)
|
|
562
|
+
: Number.POSITIVE_INFINITY;
|
|
563
|
+
const nextResize = resolveTimelineResize(
|
|
564
|
+
{
|
|
565
|
+
start: resize.element.start,
|
|
566
|
+
duration: resize.element.duration,
|
|
567
|
+
originClientX: resize.originClientX,
|
|
568
|
+
pixelsPerSecond: ppsRef.current,
|
|
569
|
+
minStart: 0,
|
|
570
|
+
maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
|
|
571
|
+
playbackStart: resize.element.playbackStart,
|
|
572
|
+
playbackRate: resize.element.playbackRate,
|
|
573
|
+
},
|
|
574
|
+
resize.edge,
|
|
575
|
+
e.clientX,
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
setResizingClip((prev) =>
|
|
579
|
+
prev
|
|
580
|
+
? {
|
|
581
|
+
...prev,
|
|
582
|
+
started: true,
|
|
583
|
+
previewStart: nextResize.start,
|
|
584
|
+
previewDuration: nextResize.duration,
|
|
585
|
+
previewPlaybackStart: nextResize.playbackStart,
|
|
586
|
+
}
|
|
587
|
+
: prev,
|
|
588
|
+
);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (!drag) return;
|
|
592
|
+
|
|
593
|
+
const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
|
|
594
|
+
if (!drag.started && distance < 4) return;
|
|
595
|
+
|
|
596
|
+
setShowPopover(false);
|
|
597
|
+
setRangeSelection(null);
|
|
598
|
+
|
|
599
|
+
setDraggedClip((prev) =>
|
|
600
|
+
prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev,
|
|
601
|
+
);
|
|
602
|
+
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const handleWindowPointerUp = () => {
|
|
606
|
+
stopClipDragAutoScrollRef.current();
|
|
607
|
+
const resize = resizingClipRef.current;
|
|
608
|
+
if (resize) {
|
|
609
|
+
resizingClipRef.current = null;
|
|
610
|
+
setResizingClip(null);
|
|
611
|
+
|
|
612
|
+
if (!resize.started) return;
|
|
613
|
+
|
|
614
|
+
suppressClickRef.current = true;
|
|
615
|
+
clearSuppressedClick();
|
|
616
|
+
|
|
617
|
+
const hasChanged =
|
|
618
|
+
resize.previewStart !== resize.element.start ||
|
|
619
|
+
resize.previewDuration !== resize.element.duration ||
|
|
620
|
+
resize.previewPlaybackStart !== resize.element.playbackStart;
|
|
621
|
+
if (!hasChanged) return;
|
|
622
|
+
|
|
623
|
+
updateElement(resize.element.key ?? resize.element.id, {
|
|
624
|
+
start: resize.previewStart,
|
|
625
|
+
duration: resize.previewDuration,
|
|
626
|
+
playbackStart: resize.previewPlaybackStart,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
Promise.resolve(
|
|
630
|
+
onResizeElementRef.current?.(resize.element, {
|
|
631
|
+
start: resize.previewStart,
|
|
632
|
+
duration: resize.previewDuration,
|
|
633
|
+
playbackStart: resize.previewPlaybackStart,
|
|
634
|
+
}),
|
|
635
|
+
).catch((error) => {
|
|
636
|
+
updateElement(resize.element.key ?? resize.element.id, {
|
|
637
|
+
start: resize.element.start,
|
|
638
|
+
duration: resize.element.duration,
|
|
639
|
+
playbackStart: resize.element.playbackStart,
|
|
640
|
+
});
|
|
641
|
+
console.error("[Timeline] Failed to persist clip resize", error);
|
|
642
|
+
});
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const drag = draggedClipRef.current;
|
|
647
|
+
if (!drag) return;
|
|
648
|
+
draggedClipRef.current = null;
|
|
649
|
+
setDraggedClip(null);
|
|
650
|
+
|
|
651
|
+
if (!drag.started) return;
|
|
652
|
+
|
|
653
|
+
suppressClickRef.current = true;
|
|
654
|
+
clearSuppressedClick();
|
|
655
|
+
|
|
656
|
+
const hasChanged =
|
|
657
|
+
drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track;
|
|
658
|
+
if (!hasChanged) return;
|
|
659
|
+
|
|
660
|
+
updateElement(drag.element.key ?? drag.element.id, {
|
|
661
|
+
start: drag.previewStart,
|
|
662
|
+
track: drag.previewTrack,
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
Promise.resolve(
|
|
666
|
+
onMoveElementRef.current?.(drag.element, {
|
|
667
|
+
start: drag.previewStart,
|
|
668
|
+
track: drag.previewTrack,
|
|
669
|
+
}),
|
|
670
|
+
).catch((error) => {
|
|
671
|
+
updateElement(drag.element.key ?? drag.element.id, {
|
|
672
|
+
start: drag.element.start,
|
|
673
|
+
track: drag.element.track,
|
|
674
|
+
});
|
|
675
|
+
console.error("[Timeline] Failed to persist clip move", error);
|
|
676
|
+
});
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
window.addEventListener("pointermove", handleWindowPointerMove);
|
|
680
|
+
window.addEventListener("pointerup", handleWindowPointerUp);
|
|
681
|
+
window.addEventListener("pointercancel", handleWindowPointerUp);
|
|
682
|
+
return () => {
|
|
683
|
+
stopClipDragAutoScrollRef.current();
|
|
684
|
+
window.removeEventListener("pointermove", handleWindowPointerMove);
|
|
685
|
+
window.removeEventListener("pointerup", handleWindowPointerUp);
|
|
686
|
+
window.removeEventListener("pointercancel", handleWindowPointerUp);
|
|
687
|
+
};
|
|
688
|
+
});
|
|
689
|
+
|
|
339
690
|
const handlePointerDown = useCallback(
|
|
340
691
|
(e: React.PointerEvent) => {
|
|
341
692
|
if (e.button !== 0) return;
|
|
@@ -402,26 +753,21 @@ export const Timeline = memo(function Timeline({
|
|
|
402
753
|
cancelAnimationFrame(dragScrollRaf.current);
|
|
403
754
|
}, []);
|
|
404
755
|
|
|
405
|
-
const tracks = useMemo(() => {
|
|
406
|
-
const map = new Map<number, typeof elements>();
|
|
407
|
-
for (const el of elements) {
|
|
408
|
-
const list = map.get(el.track) ?? [];
|
|
409
|
-
list.push(el);
|
|
410
|
-
map.set(el.track, list);
|
|
411
|
-
}
|
|
412
|
-
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
|
413
|
-
}, [elements]);
|
|
414
|
-
|
|
415
|
-
// Determine dominant style per track (from first element)
|
|
416
|
-
const trackStyles = useMemo(() => {
|
|
417
|
-
const map = new Map<number, TrackStyle>();
|
|
418
|
-
for (const [trackNum, els] of tracks) {
|
|
419
|
-
map.set(trackNum, getStyle(els[0]?.tag ?? ""));
|
|
420
|
-
}
|
|
421
|
-
return map;
|
|
422
|
-
}, [tracks]);
|
|
423
|
-
|
|
424
756
|
const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
|
|
757
|
+
const getPreviewElement = useCallback(
|
|
758
|
+
(element: TimelineElement): TimelineElement => {
|
|
759
|
+
if (resizingClip?.element.id === element.id) {
|
|
760
|
+
return {
|
|
761
|
+
...element,
|
|
762
|
+
start: resizingClip.previewStart,
|
|
763
|
+
duration: resizingClip.previewDuration,
|
|
764
|
+
playbackStart: resizingClip.previewPlaybackStart,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
return element;
|
|
768
|
+
},
|
|
769
|
+
[resizingClip],
|
|
770
|
+
);
|
|
425
771
|
|
|
426
772
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
427
773
|
|
|
@@ -522,14 +868,92 @@ export const Timeline = memo(function Timeline({
|
|
|
522
868
|
);
|
|
523
869
|
}
|
|
524
870
|
|
|
525
|
-
const totalH = RULER_H +
|
|
871
|
+
const totalH = RULER_H + displayTrackOrder.length * TRACK_H;
|
|
872
|
+
const draggedElement = draggedClip?.element ?? null;
|
|
873
|
+
const activeDraggedElement =
|
|
874
|
+
draggedClip?.started === true && draggedElement
|
|
875
|
+
? getRenderedTimelineElement({
|
|
876
|
+
element: draggedElement,
|
|
877
|
+
draggedElementId: draggedElement.id,
|
|
878
|
+
previewStart: draggedClip.previewStart,
|
|
879
|
+
previewTrack: draggedClip.previewTrack,
|
|
880
|
+
})
|
|
881
|
+
: null;
|
|
882
|
+
const activeDraggedPosition =
|
|
883
|
+
draggedClip?.started === true && activeDraggedElement && scrollRef.current
|
|
884
|
+
? {
|
|
885
|
+
left:
|
|
886
|
+
draggedClip.pointerClientX -
|
|
887
|
+
scrollRef.current.getBoundingClientRect().left +
|
|
888
|
+
scrollRef.current.scrollLeft -
|
|
889
|
+
draggedClip.pointerOffsetX,
|
|
890
|
+
top:
|
|
891
|
+
draggedClip.pointerClientY -
|
|
892
|
+
scrollRef.current.getBoundingClientRect().top +
|
|
893
|
+
scrollRef.current.scrollTop -
|
|
894
|
+
draggedClip.pointerOffsetY,
|
|
895
|
+
}
|
|
896
|
+
: null;
|
|
897
|
+
const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => {
|
|
898
|
+
return (
|
|
899
|
+
<>
|
|
900
|
+
{renderClipOverlay?.(element)}
|
|
901
|
+
<div
|
|
902
|
+
className={
|
|
903
|
+
renderClipContent
|
|
904
|
+
? "absolute inset-0 overflow-hidden"
|
|
905
|
+
: "flex flex-col justify-center overflow-hidden flex-1 min-w-0 px-6"
|
|
906
|
+
}
|
|
907
|
+
>
|
|
908
|
+
{renderClipContent?.(element, clipStyle) ?? (
|
|
909
|
+
<div className="flex h-full min-h-0 flex-col justify-between py-3">
|
|
910
|
+
<div className="flex items-start">
|
|
911
|
+
<span
|
|
912
|
+
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] leading-none"
|
|
913
|
+
style={{
|
|
914
|
+
color: clipStyle.label,
|
|
915
|
+
background: `${clipStyle.accent}26`,
|
|
916
|
+
boxShadow: `inset 0 0 0 1px ${clipStyle.accent}33`,
|
|
917
|
+
}}
|
|
918
|
+
>
|
|
919
|
+
{element.tag}
|
|
920
|
+
</span>
|
|
921
|
+
</div>
|
|
922
|
+
<span
|
|
923
|
+
className="text-[14px] font-semibold truncate leading-none tracking-[-0.02em]"
|
|
924
|
+
style={{ color: theme.textPrimary }}
|
|
925
|
+
>
|
|
926
|
+
{element.id || element.tag}
|
|
927
|
+
</span>
|
|
928
|
+
<div className="flex items-center">
|
|
929
|
+
<span
|
|
930
|
+
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none"
|
|
931
|
+
style={{
|
|
932
|
+
color: theme.textSecondary,
|
|
933
|
+
background: "rgba(255,255,255,0.04)",
|
|
934
|
+
}}
|
|
935
|
+
>
|
|
936
|
+
{formatTime(element.start)} {"\u2192"}{" "}
|
|
937
|
+
{formatTime(element.start + element.duration)}
|
|
938
|
+
</span>
|
|
939
|
+
</div>
|
|
940
|
+
</div>
|
|
941
|
+
)}
|
|
942
|
+
</div>
|
|
943
|
+
</>
|
|
944
|
+
);
|
|
945
|
+
};
|
|
526
946
|
|
|
527
947
|
return (
|
|
528
948
|
<div
|
|
529
949
|
ref={setContainerRef}
|
|
530
950
|
aria-label="Timeline"
|
|
531
|
-
className={`border-t
|
|
532
|
-
style={{
|
|
951
|
+
className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
952
|
+
style={{
|
|
953
|
+
touchAction: "pan-x pan-y",
|
|
954
|
+
background: theme.shellBackground,
|
|
955
|
+
borderColor: theme.shellBorder,
|
|
956
|
+
}}
|
|
533
957
|
>
|
|
534
958
|
<div
|
|
535
959
|
ref={scrollRef}
|
|
@@ -555,7 +979,7 @@ export const Timeline = memo(function Timeline({
|
|
|
555
979
|
y1={RULER_H}
|
|
556
980
|
x2={x}
|
|
557
981
|
y2={totalH}
|
|
558
|
-
stroke=
|
|
982
|
+
stroke={theme.tickMinor}
|
|
559
983
|
strokeWidth="1"
|
|
560
984
|
/>
|
|
561
985
|
);
|
|
@@ -564,20 +988,20 @@ export const Timeline = memo(function Timeline({
|
|
|
564
988
|
|
|
565
989
|
{/* Ruler */}
|
|
566
990
|
<div
|
|
567
|
-
className="relative
|
|
991
|
+
className="relative overflow-hidden"
|
|
568
992
|
style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
|
|
569
993
|
>
|
|
570
994
|
{/* Shift hint */}
|
|
571
995
|
{shiftHeld && !rangeSelection && (
|
|
572
996
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
|
573
|
-
<span className="text-[9px]
|
|
997
|
+
<span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
|
|
574
998
|
Drag to select range
|
|
575
999
|
</span>
|
|
576
1000
|
</div>
|
|
577
1001
|
)}
|
|
578
1002
|
{minor.map((t) => (
|
|
579
1003
|
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
|
|
580
|
-
<div className="w-px h-[3px]
|
|
1004
|
+
<div className="w-px h-[3px]" style={{ background: theme.tickMinor }} />
|
|
581
1005
|
</div>
|
|
582
1006
|
))}
|
|
583
1007
|
{major.map((t) => (
|
|
@@ -586,36 +1010,49 @@ export const Timeline = memo(function Timeline({
|
|
|
586
1010
|
className="absolute bottom-0 flex flex-col items-center"
|
|
587
1011
|
style={{ left: t * pps }}
|
|
588
1012
|
>
|
|
589
|
-
<span
|
|
1013
|
+
<span
|
|
1014
|
+
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
|
|
1015
|
+
style={{ color: theme.tickText }}
|
|
1016
|
+
>
|
|
590
1017
|
{formatTime(t)}
|
|
591
1018
|
</span>
|
|
592
|
-
<div className="w-px h-[5px]
|
|
1019
|
+
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
|
|
593
1020
|
</div>
|
|
594
1021
|
))}
|
|
595
1022
|
</div>
|
|
596
1023
|
|
|
597
1024
|
{/* Tracks */}
|
|
598
|
-
{
|
|
599
|
-
const
|
|
1025
|
+
{displayTrackOrder.map((trackNum) => {
|
|
1026
|
+
const els = tracks.find(([currentTrack]) => currentTrack === trackNum)?.[1] ?? [];
|
|
1027
|
+
const ts = trackStyles.get(trackNum) ?? getStyle("");
|
|
1028
|
+
const isPendingTrack =
|
|
1029
|
+
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
600
1030
|
return (
|
|
601
1031
|
<div
|
|
602
1032
|
key={trackNum}
|
|
603
1033
|
className="relative flex"
|
|
604
|
-
style={{
|
|
1034
|
+
style={{
|
|
1035
|
+
height: TRACK_H,
|
|
1036
|
+
background: theme.rowBackground,
|
|
1037
|
+
borderBottom: `1px solid ${theme.rowBorder}`,
|
|
1038
|
+
}}
|
|
605
1039
|
>
|
|
606
|
-
{/* Gutter: colored icon badge (Figma Motion Cut style) */}
|
|
607
1040
|
<div
|
|
608
1041
|
className="flex-shrink-0 flex items-center justify-center"
|
|
609
|
-
style={{
|
|
1042
|
+
style={{
|
|
1043
|
+
width: GUTTER,
|
|
1044
|
+
background: theme.gutterBackground,
|
|
1045
|
+
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
1046
|
+
}}
|
|
610
1047
|
>
|
|
611
1048
|
<div
|
|
612
1049
|
className="flex items-center justify-center"
|
|
613
1050
|
style={{
|
|
614
|
-
width:
|
|
615
|
-
height:
|
|
1051
|
+
width: 18,
|
|
1052
|
+
height: 18,
|
|
616
1053
|
borderRadius: 6,
|
|
617
|
-
backgroundColor: ts.
|
|
618
|
-
border:
|
|
1054
|
+
backgroundColor: ts.iconBackground,
|
|
1055
|
+
border: `1px solid ${theme.gutterBorder}`,
|
|
619
1056
|
color: "#fff",
|
|
620
1057
|
}}
|
|
621
1058
|
>
|
|
@@ -625,64 +1062,98 @@ export const Timeline = memo(function Timeline({
|
|
|
625
1062
|
|
|
626
1063
|
{/* Clips */}
|
|
627
1064
|
<div style={{ width: trackContentWidth }} className="relative">
|
|
1065
|
+
{isPendingTrack && (
|
|
1066
|
+
<div
|
|
1067
|
+
className="absolute inset-0 flex items-center"
|
|
1068
|
+
style={{
|
|
1069
|
+
paddingLeft: 16,
|
|
1070
|
+
color: ts.label,
|
|
1071
|
+
fontSize: 11,
|
|
1072
|
+
letterSpacing: "0.08em",
|
|
1073
|
+
textTransform: "uppercase",
|
|
1074
|
+
background: `linear-gradient(90deg, ${ts.accent}14, transparent 28%)`,
|
|
1075
|
+
boxShadow: `inset 0 0 0 1px ${ts.accent}24`,
|
|
1076
|
+
}}
|
|
1077
|
+
>
|
|
1078
|
+
New track
|
|
1079
|
+
</div>
|
|
1080
|
+
)}
|
|
628
1081
|
{els.map((el, i) => {
|
|
629
1082
|
const clipStyle = getStyle(el.tag);
|
|
630
|
-
const
|
|
1083
|
+
const elementKey = el.key ?? el.id;
|
|
1084
|
+
const isSelected = selectedElementId === elementKey;
|
|
631
1085
|
const isComposition = !!el.compositionSrc;
|
|
632
|
-
const clipKey = `${
|
|
1086
|
+
const clipKey = `${elementKey}-${i}`;
|
|
633
1087
|
const isHovered = hoveredClip === clipKey;
|
|
634
1088
|
const hasCustomContent = !!renderClipContent;
|
|
635
|
-
const
|
|
1089
|
+
const isDragging =
|
|
1090
|
+
draggedClip?.started === true &&
|
|
1091
|
+
(draggedElement?.key ?? draggedElement?.id) === elementKey;
|
|
1092
|
+
if (isDragging) return null;
|
|
1093
|
+
const previewElement = getPreviewElement(el);
|
|
636
1094
|
|
|
637
1095
|
return (
|
|
638
1096
|
<TimelineClip
|
|
639
1097
|
key={clipKey}
|
|
640
|
-
el={
|
|
1098
|
+
el={previewElement}
|
|
641
1099
|
pps={pps}
|
|
642
1100
|
clipY={CLIP_Y}
|
|
643
1101
|
isSelected={isSelected}
|
|
644
1102
|
isHovered={isHovered}
|
|
1103
|
+
isDragging={false}
|
|
645
1104
|
hasCustomContent={hasCustomContent}
|
|
646
|
-
|
|
1105
|
+
theme={theme}
|
|
1106
|
+
trackStyle={clipStyle}
|
|
647
1107
|
isComposition={isComposition}
|
|
648
1108
|
onHoverStart={() => setHoveredClip(clipKey)}
|
|
649
1109
|
onHoverEnd={() => setHoveredClip(null)}
|
|
1110
|
+
onResizeStart={(edge, e) => {
|
|
1111
|
+
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
|
1112
|
+
e.stopPropagation();
|
|
1113
|
+
setShowPopover(false);
|
|
1114
|
+
setRangeSelection(null);
|
|
1115
|
+
setResizingClip({
|
|
1116
|
+
element: el,
|
|
1117
|
+
edge,
|
|
1118
|
+
originClientX: e.clientX,
|
|
1119
|
+
previewStart: el.start,
|
|
1120
|
+
previewDuration: el.duration,
|
|
1121
|
+
previewPlaybackStart: el.playbackStart,
|
|
1122
|
+
started: false,
|
|
1123
|
+
});
|
|
1124
|
+
}}
|
|
1125
|
+
onPointerDown={(e) => {
|
|
1126
|
+
if (e.button !== 0 || e.shiftKey || !onMoveElement) return;
|
|
1127
|
+
setShowPopover(false);
|
|
1128
|
+
setRangeSelection(null);
|
|
1129
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
1130
|
+
setDraggedClip({
|
|
1131
|
+
element: el,
|
|
1132
|
+
originClientX: e.clientX,
|
|
1133
|
+
originClientY: e.clientY,
|
|
1134
|
+
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
1135
|
+
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
|
1136
|
+
pointerClientX: e.clientX,
|
|
1137
|
+
pointerClientY: e.clientY,
|
|
1138
|
+
pointerOffsetX: e.clientX - rect.left,
|
|
1139
|
+
pointerOffsetY: e.clientY - rect.top,
|
|
1140
|
+
previewStart: el.start,
|
|
1141
|
+
previewTrack: el.track,
|
|
1142
|
+
started: false,
|
|
1143
|
+
});
|
|
1144
|
+
}}
|
|
650
1145
|
onClick={(e) => {
|
|
651
1146
|
e.stopPropagation();
|
|
652
|
-
|
|
1147
|
+
if (suppressClickRef.current) return;
|
|
1148
|
+
setSelectedElementId(isSelected ? null : elementKey);
|
|
653
1149
|
}}
|
|
654
1150
|
onDoubleClick={(e) => {
|
|
655
1151
|
e.stopPropagation();
|
|
1152
|
+
if (suppressClickRef.current) return;
|
|
656
1153
|
if (isComposition && onDrillDown) onDrillDown(el);
|
|
657
1154
|
}}
|
|
658
1155
|
>
|
|
659
|
-
{
|
|
660
|
-
<div
|
|
661
|
-
className={
|
|
662
|
-
renderClipContent
|
|
663
|
-
? "absolute inset-0 overflow-hidden rounded-[4px]"
|
|
664
|
-
: "flex items-center overflow-hidden flex-1 min-w-0"
|
|
665
|
-
}
|
|
666
|
-
>
|
|
667
|
-
{renderClipContent?.(el, clipStyle) ?? (
|
|
668
|
-
<>
|
|
669
|
-
<span
|
|
670
|
-
className="text-[10px] font-semibold truncate px-1.5 leading-none"
|
|
671
|
-
style={{ color: clipStyle.label }}
|
|
672
|
-
>
|
|
673
|
-
{el.id || el.tag}
|
|
674
|
-
</span>
|
|
675
|
-
{clipWidthPx > 60 && (
|
|
676
|
-
<span
|
|
677
|
-
className="text-[9px] font-mono tabular-nums pr-1.5 ml-auto flex-shrink-0 leading-none opacity-70"
|
|
678
|
-
style={{ color: clipStyle.label }}
|
|
679
|
-
>
|
|
680
|
-
{el.duration.toFixed(1)}s
|
|
681
|
-
</span>
|
|
682
|
-
)}
|
|
683
|
-
</>
|
|
684
|
-
)}
|
|
685
|
-
</div>
|
|
1156
|
+
{renderClipChildren(previewElement, clipStyle)}
|
|
686
1157
|
</TimelineClip>
|
|
687
1158
|
);
|
|
688
1159
|
})}
|
|
@@ -691,6 +1162,41 @@ export const Timeline = memo(function Timeline({
|
|
|
691
1162
|
);
|
|
692
1163
|
})}
|
|
693
1164
|
|
|
1165
|
+
{activeDraggedElement && activeDraggedPosition && (
|
|
1166
|
+
<div
|
|
1167
|
+
className="absolute pointer-events-none"
|
|
1168
|
+
style={{
|
|
1169
|
+
top: activeDraggedPosition.top,
|
|
1170
|
+
left: activeDraggedPosition.left,
|
|
1171
|
+
width: Math.max(activeDraggedElement.duration * pps, 4),
|
|
1172
|
+
height: TRACK_H - CLIP_Y * 2,
|
|
1173
|
+
zIndex: 40,
|
|
1174
|
+
}}
|
|
1175
|
+
>
|
|
1176
|
+
<TimelineClip
|
|
1177
|
+
el={{ ...activeDraggedElement, start: 0 }}
|
|
1178
|
+
pps={pps}
|
|
1179
|
+
clipY={0}
|
|
1180
|
+
isSelected={
|
|
1181
|
+
selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)
|
|
1182
|
+
}
|
|
1183
|
+
isHovered={false}
|
|
1184
|
+
isDragging={true}
|
|
1185
|
+
hasCustomContent={!!renderClipContent}
|
|
1186
|
+
theme={theme}
|
|
1187
|
+
trackStyle={getStyle(activeDraggedElement.tag)}
|
|
1188
|
+
isComposition={!!activeDraggedElement.compositionSrc}
|
|
1189
|
+
onHoverStart={() => {}}
|
|
1190
|
+
onHoverEnd={() => {}}
|
|
1191
|
+
onResizeStart={() => {}}
|
|
1192
|
+
onClick={() => {}}
|
|
1193
|
+
onDoubleClick={() => {}}
|
|
1194
|
+
>
|
|
1195
|
+
{renderClipChildren(activeDraggedElement, getStyle(activeDraggedElement.tag))}
|
|
1196
|
+
</TimelineClip>
|
|
1197
|
+
</div>
|
|
1198
|
+
)}
|
|
1199
|
+
|
|
694
1200
|
{/* Range selection highlight */}
|
|
695
1201
|
{rangeSelection && (
|
|
696
1202
|
<div
|
|
@@ -746,11 +1252,22 @@ export const Timeline = memo(function Timeline({
|
|
|
746
1252
|
{/* Keyboard shortcut hint — always visible */}
|
|
747
1253
|
{!showPopover && !rangeSelection && (
|
|
748
1254
|
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
|
749
|
-
<div
|
|
750
|
-
|
|
1255
|
+
<div
|
|
1256
|
+
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
|
1257
|
+
style={{
|
|
1258
|
+
background: "rgba(17,23,35,0.84)",
|
|
1259
|
+
borderColor: theme.gutterBorder,
|
|
1260
|
+
}}
|
|
1261
|
+
>
|
|
1262
|
+
<kbd
|
|
1263
|
+
className="text-[9px] font-mono px-1 py-0.5 rounded"
|
|
1264
|
+
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
|
|
1265
|
+
>
|
|
751
1266
|
Shift
|
|
752
1267
|
</kbd>
|
|
753
|
-
<span className="text-[9px]
|
|
1268
|
+
<span className="text-[9px]" style={{ color: theme.textSecondary }}>
|
|
1269
|
+
+ drag to edit range
|
|
1270
|
+
</span>
|
|
754
1271
|
</div>
|
|
755
1272
|
</div>
|
|
756
1273
|
)}
|