@hyperframes/studio 0.6.0-alpha.9 → 0.6.1

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