@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.
Files changed (58) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-hYc4aP7M.js +117 -0
  3. package/dist/index.html +1 -1
  4. package/package.json +4 -4
  5. package/src/App.tsx +2 -13
  6. package/src/captions/components/CaptionOverlay.tsx +13 -246
  7. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  8. package/src/components/StudioPreviewArea.tsx +6 -2
  9. package/src/components/editor/DomEditOverlay.tsx +88 -1007
  10. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  11. package/src/components/editor/FileTree.tsx +13 -621
  12. package/src/components/editor/FileTreeIcons.tsx +128 -0
  13. package/src/components/editor/FileTreeNodes.tsx +496 -0
  14. package/src/components/editor/MotionPanel.tsx +16 -390
  15. package/src/components/editor/MotionPanelFields.tsx +185 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  19. package/src/components/editor/domEditing.ts +44 -1150
  20. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  21. package/src/components/editor/domEditingDom.ts +266 -0
  22. package/src/components/editor/domEditingElement.ts +329 -0
  23. package/src/components/editor/domEditingLayers.ts +460 -0
  24. package/src/components/editor/domEditingTypes.ts +125 -0
  25. package/src/components/editor/manualEdits.ts +84 -1081
  26. package/src/components/editor/manualEditsDom.ts +436 -0
  27. package/src/components/editor/manualEditsParsing.ts +280 -0
  28. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  29. package/src/components/editor/manualEditsTypes.ts +141 -0
  30. package/src/components/editor/studioMotion.ts +47 -434
  31. package/src/components/editor/studioMotionOps.ts +299 -0
  32. package/src/components/editor/studioMotionTypes.ts +168 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  34. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  35. package/src/components/nle/NLELayout.tsx +60 -144
  36. package/src/components/nle/useCompositionStack.ts +126 -0
  37. package/src/hooks/useToast.ts +20 -0
  38. package/src/player/components/Timeline.tsx +189 -1418
  39. package/src/player/components/TimelineCanvas.tsx +434 -0
  40. package/src/player/components/TimelineEmptyState.tsx +102 -0
  41. package/src/player/components/TimelineRuler.tsx +90 -0
  42. package/src/player/components/timelineIcons.tsx +49 -0
  43. package/src/player/components/timelineLayout.ts +215 -0
  44. package/src/player/components/timelineUtils.ts +211 -0
  45. package/src/player/components/useTimelineClipDrag.ts +388 -0
  46. package/src/player/components/useTimelinePlayhead.ts +200 -0
  47. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  48. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  49. package/src/player/hooks/useTimelinePlayer.ts +69 -1372
  50. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  51. package/src/player/lib/playbackAdapter.ts +145 -0
  52. package/src/player/lib/playbackShortcuts.ts +68 -0
  53. package/src/player/lib/playbackTypes.ts +60 -0
  54. package/src/player/lib/timelineDOM.ts +373 -0
  55. package/src/player/lib/timelineElementHelpers.ts +303 -0
  56. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  57. package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
  58. 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
- buildClipRangeSelection,
14
- getTimelineEditCapabilities,
15
- resolveBlockedTimelineEditIntent,
16
- resolveTimelineAutoScroll,
17
- resolveTimelineMove,
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
- /** Called when user double-clicks a composition clip to drill into it */
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: import("../store/playerStore").TimelineElement,
43
+ element: TimelineElement,
299
44
  style: { clip: string; label: string },
300
45
  ) => ReactNode;
301
- /** Optional overlay renderer for clips (e.g. badges, cursors) */
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
- /** Persist a clip move back into source HTML */
314
- onDeleteElement?: (
315
- element: import("../store/playerStore").TimelineElement,
316
- ) => Promise<void> | void;
55
+ onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
317
56
  onMoveElement?: (
318
- element: import("../store/playerStore").TimelineElement,
319
- updates: Pick<import("../store/playerStore").TimelineElement, "start" | "track">,
57
+ element: TimelineElement,
58
+ updates: Pick<TimelineElement, "start" | "track">,
320
59
  ) => Promise<void> | void;
321
60
  onResizeElement?: (
322
- element: import("../store/playerStore").TimelineElement,
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
- element: import("../store/playerStore").TimelineElement,
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
- const isRangeSelecting = useRef(false);
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, getStyle(els[0]?.tag ?? ""));
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 durationRef = useRef(effectiveDuration);
552
- durationRef.current = effectiveDuration;
553
- const ppsRef = useRef(pps);
554
- ppsRef.current = pps;
555
- const syncPlayheadPosition = useCallback((time: number) => {
556
- if (!playheadRef.current || durationRef.current <= 0) return;
557
- playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current)}px`;
558
- }, []);
559
-
560
- useEffect(() => {
561
- syncPlayheadPosition(currentTime);
562
- }, [currentTime, pps, syncPlayheadPosition]);
563
-
564
- useEffect(() => {
565
- const scroll = scrollRef.current;
566
- if (!scroll) {
567
- previousZoomModeRef.current = zoomMode;
568
- return;
569
- }
570
- scroll.scrollLeft = getTimelineScrollLeftForZoomTransition(
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 dragScrollRaf = useRef(0);
608
- const clipDragScrollRaf = useRef(0);
609
- const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
610
-
611
- const updateDraggedClipPreview = useCallback(
612
- (drag: DraggedClipState, clientX: number, clientY: number) => {
613
- const scroll = scrollRef.current;
614
- const nextMove = resolveTimelineMove(
615
- {
616
- start: drag.element.start,
617
- track: drag.element.track,
618
- duration: drag.element.duration,
619
- originClientX: drag.originClientX,
620
- originClientY: drag.originClientY,
621
- originScrollLeft: drag.originScrollLeft,
622
- originScrollTop: drag.originScrollTop,
623
- currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
624
- currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
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
- const scroll = scrollRef.current;
1074
- const rect = scroll?.getBoundingClientRect();
1075
- const placement =
1076
- scroll && rect
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 (!parsed.path) return;
1101
- const scroll = scrollRef.current;
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
- // ignore malformed drag payloads
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
- <div
1180
- className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
1181
- isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
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
- <div className="relative" style={{ height: totalH, width: GUTTER + trackContentWidth }}>
1359
- {/* Grid lines */}
1360
- <svg
1361
- className="absolute pointer-events-none"
1362
- style={{ left: GUTTER, width: trackContentWidth }}
1363
- height={totalH}
1364
- >
1365
- {major.map((t) => {
1366
- const x = t * pps;
1367
- return (
1368
- <line
1369
- key={`g-${t}`}
1370
- x1={x}
1371
- y1={RULER_H}
1372
- x2={x}
1373
- y2={totalH}
1374
- stroke={theme.tickMinor}
1375
- strokeWidth="1"
1376
- />
1377
- );
1378
- })}
1379
- </svg>
1380
-
1381
- {/* Ruler */}
1382
- <div
1383
- className="relative overflow-hidden"
1384
- style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
1385
- >
1386
- {/* Shift hint */}
1387
- {shiftHeld && !rangeSelection && (
1388
- <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
1389
- <span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
1390
- Drag or click a clip to edit range
1391
- </span>
1392
- </div>
1393
- )}
1394
- {minor.map((t) => (
1395
- <div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
1396
- <div className="w-px h-[3px]" style={{ background: theme.tickMinor }} />
1397
- </div>
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}