@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
@@ -0,0 +1,388 @@
1
+ import { useRef, useState, useCallback } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import {
4
+ resolveTimelineMove,
5
+ resolveTimelineResize,
6
+ resolveTimelineAutoScroll,
7
+ type BlockedTimelineEditIntent,
8
+ } from "./timelineEditing";
9
+ import { usePlayerStore } from "../store/playerStore";
10
+ import type { TimelineElement } from "../store/playerStore";
11
+ import { TRACK_H } from "./timelineLayout";
12
+
13
+ /* ── Shared state types ─────────────────────────────────────────── */
14
+ export interface DraggedClipState {
15
+ element: TimelineElement;
16
+ originClientX: number;
17
+ originClientY: number;
18
+ originScrollLeft: number;
19
+ originScrollTop: number;
20
+ pointerClientX: number;
21
+ pointerClientY: number;
22
+ pointerOffsetX: number;
23
+ pointerOffsetY: number;
24
+ previewStart: number;
25
+ previewTrack: number;
26
+ started: boolean;
27
+ }
28
+
29
+ export interface ResizingClipState {
30
+ element: TimelineElement;
31
+ edge: "start" | "end";
32
+ originClientX: number;
33
+ previewStart: number;
34
+ previewDuration: number;
35
+ previewPlaybackStart?: number;
36
+ started: boolean;
37
+ }
38
+
39
+ export interface BlockedClipState {
40
+ element: TimelineElement;
41
+ intent: BlockedTimelineEditIntent;
42
+ originClientX: number;
43
+ originClientY: number;
44
+ started: boolean;
45
+ }
46
+
47
+ /* ── Hook ───────────────────────────────────────────────────────── */
48
+ interface UseTimelineClipDragInput {
49
+ scrollRef: React.RefObject<HTMLDivElement | null>;
50
+ ppsRef: React.RefObject<number>;
51
+ durationRef: React.RefObject<number>;
52
+ trackOrderRef: React.RefObject<number[]>;
53
+ onMoveElement?: (
54
+ element: TimelineElement,
55
+ updates: Pick<TimelineElement, "start" | "track">,
56
+ ) => Promise<void> | void;
57
+ onResizeElement?: (
58
+ element: TimelineElement,
59
+ updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
60
+ ) => Promise<void> | void;
61
+ onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
62
+ setShowPopover: (show: boolean) => void;
63
+ /** Stable ref to the range selection setter — wired after mount to break circular dependency. */
64
+ setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
65
+ }
66
+
67
+ export function useTimelineClipDrag({
68
+ scrollRef,
69
+ ppsRef,
70
+ durationRef,
71
+ trackOrderRef,
72
+ onMoveElement,
73
+ onResizeElement,
74
+ onBlockedEditAttempt,
75
+ setShowPopover,
76
+ setRangeSelectionRef,
77
+ }: UseTimelineClipDragInput) {
78
+ const updateElement = usePlayerStore((s) => s.updateElement);
79
+
80
+ const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
81
+ const draggedClipRef = useRef<DraggedClipState | null>(null);
82
+ draggedClipRef.current = draggedClip;
83
+
84
+ const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
85
+ const resizingClipRef = useRef<ResizingClipState | null>(null);
86
+ resizingClipRef.current = resizingClip;
87
+
88
+ const blockedClipRef = useRef<BlockedClipState | null>(null);
89
+ const suppressClickRef = useRef(false);
90
+
91
+ const onMoveElementRef = useRef(onMoveElement);
92
+ onMoveElementRef.current = onMoveElement;
93
+ const onResizeElementRef = useRef(onResizeElement);
94
+ onResizeElementRef.current = onResizeElement;
95
+
96
+ const clipDragScrollRaf = useRef(0);
97
+ const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
98
+
99
+ const updateDraggedClipPreview = useCallback(
100
+ (drag: DraggedClipState, clientX: number, clientY: number): DraggedClipState => {
101
+ const scroll = scrollRef.current;
102
+ const nextMove = resolveTimelineMove(
103
+ {
104
+ start: drag.element.start,
105
+ track: drag.element.track,
106
+ duration: drag.element.duration,
107
+ originClientX: drag.originClientX,
108
+ originClientY: drag.originClientY,
109
+ originScrollLeft: drag.originScrollLeft,
110
+ originScrollTop: drag.originScrollTop,
111
+ currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
112
+ currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
113
+ pixelsPerSecond: ppsRef.current,
114
+ trackHeight: TRACK_H,
115
+ maxStart: Math.max(0, durationRef.current - drag.element.duration),
116
+ trackOrder: trackOrderRef.current,
117
+ },
118
+ clientX,
119
+ clientY,
120
+ );
121
+ return {
122
+ ...drag,
123
+ started: true,
124
+ pointerClientX: clientX,
125
+ pointerClientY: clientY,
126
+ previewStart: nextMove.start,
127
+ previewTrack: nextMove.track,
128
+ };
129
+ },
130
+ [scrollRef, ppsRef, durationRef, trackOrderRef],
131
+ );
132
+
133
+ const stopClipDragAutoScroll = useCallback(() => {
134
+ clipDragPointerRef.current = null;
135
+ if (clipDragScrollRaf.current) {
136
+ cancelAnimationFrame(clipDragScrollRaf.current);
137
+ clipDragScrollRaf.current = 0;
138
+ }
139
+ }, []);
140
+
141
+ const stepClipDragAutoScroll = useCallback(() => {
142
+ clipDragScrollRaf.current = 0;
143
+ const drag = draggedClipRef.current;
144
+ const pointer = clipDragPointerRef.current;
145
+ const scroll = scrollRef.current;
146
+ if (!drag || !pointer || !scroll) return;
147
+
148
+ const rect = scroll.getBoundingClientRect();
149
+ const delta = resolveTimelineAutoScroll(rect, pointer.clientX, pointer.clientY);
150
+ if (delta.x === 0 && delta.y === 0) return;
151
+
152
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
153
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
154
+ const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
155
+ const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
156
+ if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return;
157
+
158
+ scroll.scrollLeft = nextScrollLeft;
159
+ scroll.scrollTop = nextScrollTop;
160
+ setDraggedClip((prev) =>
161
+ prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
162
+ );
163
+ clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
164
+ }, [scrollRef, updateDraggedClipPreview]);
165
+
166
+ const syncClipDragAutoScroll = useCallback(
167
+ (clientX: number, clientY: number) => {
168
+ clipDragPointerRef.current = { clientX, clientY };
169
+ const scroll = scrollRef.current;
170
+ if (!scroll) return;
171
+ const rect = scroll.getBoundingClientRect();
172
+ const delta = resolveTimelineAutoScroll(rect, clientX, clientY);
173
+ if (delta.x === 0 && delta.y === 0) {
174
+ if (clipDragScrollRaf.current) {
175
+ cancelAnimationFrame(clipDragScrollRaf.current);
176
+ clipDragScrollRaf.current = 0;
177
+ }
178
+ return;
179
+ }
180
+ if (!clipDragScrollRaf.current) {
181
+ clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
182
+ }
183
+ },
184
+ [scrollRef, stepClipDragAutoScroll],
185
+ );
186
+
187
+ const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview);
188
+ updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
189
+ const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
190
+ syncClipDragAutoScrollRef.current = syncClipDragAutoScroll;
191
+ const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
192
+ stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
193
+
194
+ useMountEffect(() => {
195
+ const clearSuppressedClick = () => {
196
+ requestAnimationFrame(() => {
197
+ suppressClickRef.current = false;
198
+ });
199
+ };
200
+
201
+ const handleWindowPointerMove = (e: PointerEvent) => {
202
+ const drag = draggedClipRef.current;
203
+ const resize = resizingClipRef.current;
204
+ const blocked = blockedClipRef.current;
205
+
206
+ if (resize) {
207
+ const distance = Math.abs(e.clientX - resize.originClientX);
208
+ if (!resize.started && distance < 2) return;
209
+
210
+ setShowPopover(false);
211
+ setRangeSelectionRef.current?.(null);
212
+
213
+ const sourceRemaining =
214
+ resize.element.sourceDuration != null
215
+ ? Math.max(
216
+ 0,
217
+ (resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) /
218
+ Math.max(resize.element.playbackRate ?? 1, 0.1),
219
+ )
220
+ : Number.POSITIVE_INFINITY;
221
+ const normalizedTag = resize.element.tag.toLowerCase();
222
+ const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
223
+ const nextResize = resolveTimelineResize(
224
+ {
225
+ start: resize.element.start,
226
+ duration: resize.element.duration,
227
+ originClientX: resize.originClientX,
228
+ pixelsPerSecond: ppsRef.current,
229
+ minStart: 0,
230
+ maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
231
+ playbackStart:
232
+ resize.edge === "start" && canSeedPlaybackStart
233
+ ? (resize.element.playbackStart ?? 0)
234
+ : resize.element.playbackStart,
235
+ playbackRate: resize.element.playbackRate,
236
+ },
237
+ resize.edge,
238
+ e.clientX,
239
+ );
240
+
241
+ setResizingClip((prev) =>
242
+ prev
243
+ ? {
244
+ ...prev,
245
+ started: true,
246
+ previewStart: nextResize.start,
247
+ previewDuration: nextResize.duration,
248
+ previewPlaybackStart: nextResize.playbackStart,
249
+ }
250
+ : prev,
251
+ );
252
+ return;
253
+ }
254
+
255
+ if (blocked) {
256
+ const distance = Math.hypot(
257
+ e.clientX - blocked.originClientX,
258
+ e.clientY - blocked.originClientY,
259
+ );
260
+ const threshold = blocked.intent === "move" ? 4 : 2;
261
+ if (!blocked.started && distance < threshold) return;
262
+ if (!blocked.started) {
263
+ blocked.started = true;
264
+ blockedClipRef.current = blocked;
265
+ suppressClickRef.current = true;
266
+ setShowPopover(false);
267
+ setRangeSelectionRef.current?.(null);
268
+ onBlockedEditAttempt?.(blocked.element, blocked.intent);
269
+ }
270
+ return;
271
+ }
272
+
273
+ if (!drag) return;
274
+ const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
275
+ if (!drag.started && distance < 4) return;
276
+
277
+ setShowPopover(false);
278
+ setRangeSelectionRef.current?.(null);
279
+
280
+ setDraggedClip((prev) =>
281
+ prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev,
282
+ );
283
+ syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
284
+ };
285
+
286
+ const handleWindowPointerUp = () => {
287
+ stopClipDragAutoScrollRef.current();
288
+
289
+ const resize = resizingClipRef.current;
290
+ if (resize) {
291
+ resizingClipRef.current = null;
292
+ setResizingClip(null);
293
+ if (!resize.started) return;
294
+
295
+ suppressClickRef.current = true;
296
+ clearSuppressedClick();
297
+
298
+ const hasChanged =
299
+ resize.previewStart !== resize.element.start ||
300
+ resize.previewDuration !== resize.element.duration ||
301
+ resize.previewPlaybackStart !== resize.element.playbackStart;
302
+ if (!hasChanged) return;
303
+
304
+ updateElement(resize.element.key ?? resize.element.id, {
305
+ start: resize.previewStart,
306
+ duration: resize.previewDuration,
307
+ playbackStart: resize.previewPlaybackStart,
308
+ });
309
+
310
+ Promise.resolve(
311
+ onResizeElementRef.current?.(resize.element, {
312
+ start: resize.previewStart,
313
+ duration: resize.previewDuration,
314
+ playbackStart: resize.previewPlaybackStart,
315
+ }),
316
+ ).catch((error) => {
317
+ updateElement(resize.element.key ?? resize.element.id, {
318
+ start: resize.element.start,
319
+ duration: resize.element.duration,
320
+ playbackStart: resize.element.playbackStart,
321
+ });
322
+ console.error("[Timeline] Failed to persist clip resize", error);
323
+ });
324
+ return;
325
+ }
326
+
327
+ const blocked = blockedClipRef.current;
328
+ if (blocked) {
329
+ blockedClipRef.current = null;
330
+ if (!blocked.started) return;
331
+ clearSuppressedClick();
332
+ return;
333
+ }
334
+
335
+ const drag = draggedClipRef.current;
336
+ if (!drag) return;
337
+ draggedClipRef.current = null;
338
+ setDraggedClip(null);
339
+ if (!drag.started) return;
340
+
341
+ suppressClickRef.current = true;
342
+ clearSuppressedClick();
343
+
344
+ const hasChanged =
345
+ drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track;
346
+ if (!hasChanged) return;
347
+
348
+ updateElement(drag.element.key ?? drag.element.id, {
349
+ start: drag.previewStart,
350
+ track: drag.previewTrack,
351
+ });
352
+
353
+ Promise.resolve(
354
+ onMoveElementRef.current?.(drag.element, {
355
+ start: drag.previewStart,
356
+ track: drag.previewTrack,
357
+ }),
358
+ ).catch((error) => {
359
+ updateElement(drag.element.key ?? drag.element.id, {
360
+ start: drag.element.start,
361
+ track: drag.element.track,
362
+ });
363
+ console.error("[Timeline] Failed to persist clip move", error);
364
+ });
365
+ };
366
+
367
+ window.addEventListener("pointermove", handleWindowPointerMove);
368
+ window.addEventListener("pointerup", handleWindowPointerUp);
369
+ window.addEventListener("pointercancel", handleWindowPointerUp);
370
+ return () => {
371
+ stopClipDragAutoScrollRef.current();
372
+ window.removeEventListener("pointermove", handleWindowPointerMove);
373
+ window.removeEventListener("pointerup", handleWindowPointerUp);
374
+ window.removeEventListener("pointercancel", handleWindowPointerUp);
375
+ };
376
+ });
377
+
378
+ return {
379
+ draggedClip,
380
+ setDraggedClip,
381
+ resizingClip,
382
+ setResizingClip,
383
+ blockedClipRef,
384
+ suppressClickRef,
385
+ syncClipDragAutoScroll,
386
+ stopClipDragAutoScroll,
387
+ };
388
+ }
@@ -0,0 +1,200 @@
1
+ import { useRef, useCallback, useEffect } from "react";
2
+ import { liveTime, type ZoomMode } from "../store/playerStore";
3
+ import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import { getPinchTimelineZoomPercent } from "./timelineZoom";
5
+ import {
6
+ GUTTER,
7
+ getTimelinePlayheadLeft,
8
+ getTimelineScrollLeftForZoomTransition,
9
+ getTimelineScrollLeftForZoomAnchor,
10
+ shouldAutoScrollTimeline,
11
+ } from "./timelineLayout";
12
+
13
+ interface UseTimelinePlayheadInput {
14
+ playheadRef: React.RefObject<HTMLDivElement | null>;
15
+ scrollRef: React.RefObject<HTMLDivElement | null>;
16
+ ppsRef: React.RefObject<number>;
17
+ durationRef: React.RefObject<number>;
18
+ isDragging: React.RefObject<boolean>;
19
+ currentTime: number;
20
+ zoomMode: ZoomMode;
21
+ manualZoomPercent: number;
22
+ zoomModeRef: React.RefObject<ZoomMode>;
23
+ manualZoomPercentRef: React.RefObject<number>;
24
+ fitPps: number;
25
+ fitPpsRef: React.RefObject<number>;
26
+ effectiveDuration: number;
27
+ pps: number;
28
+ timelineReady: boolean;
29
+ elementsLength: number;
30
+ setZoomMode: (mode: ZoomMode) => void;
31
+ setManualZoomPercent: (percent: number) => void;
32
+ onSeek?: (time: number) => void;
33
+ }
34
+
35
+ export function useTimelinePlayhead({
36
+ playheadRef,
37
+ scrollRef,
38
+ ppsRef,
39
+ durationRef,
40
+ isDragging,
41
+ currentTime,
42
+ zoomMode,
43
+ zoomModeRef,
44
+ manualZoomPercentRef,
45
+ fitPps: _fitPps,
46
+ fitPpsRef,
47
+ effectiveDuration,
48
+ pps,
49
+ timelineReady,
50
+ elementsLength,
51
+ setZoomMode,
52
+ setManualZoomPercent,
53
+ onSeek,
54
+ }: UseTimelinePlayheadInput) {
55
+ const dragScrollRaf = useRef(0);
56
+ const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
57
+
58
+ const syncPlayheadPosition = useCallback(
59
+ (time: number) => {
60
+ if (!playheadRef.current || durationRef.current <= 0) return;
61
+ playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current)}px`;
62
+ },
63
+ [playheadRef, durationRef, ppsRef],
64
+ );
65
+
66
+ useEffect(() => {
67
+ syncPlayheadPosition(currentTime);
68
+ }, [currentTime, pps, syncPlayheadPosition]);
69
+
70
+ useEffect(() => {
71
+ const scroll = scrollRef.current;
72
+ if (!scroll) {
73
+ previousZoomModeRef.current = zoomMode;
74
+ return;
75
+ }
76
+ scroll.scrollLeft = getTimelineScrollLeftForZoomTransition(
77
+ previousZoomModeRef.current,
78
+ zoomMode,
79
+ scroll.scrollLeft,
80
+ );
81
+ previousZoomModeRef.current = zoomMode;
82
+ }, [zoomMode, scrollRef]);
83
+
84
+ useMountEffect(() => {
85
+ const unsub = liveTime.subscribe((t) => {
86
+ if (!playheadRef.current || durationRef.current <= 0) return;
87
+ const playheadX = getTimelinePlayheadLeft(t, ppsRef.current);
88
+ playheadRef.current.style.left = `${playheadX}px`;
89
+ const scroll = scrollRef.current;
90
+ if (
91
+ scroll &&
92
+ !isDragging.current &&
93
+ shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth)
94
+ ) {
95
+ const edgeMargin = scroll.clientWidth * 0.12;
96
+ if (playheadX > scroll.scrollLeft + scroll.clientWidth - edgeMargin)
97
+ scroll.scrollLeft = playheadX - scroll.clientWidth * 0.15;
98
+ else if (playheadX < scroll.scrollLeft + GUTTER)
99
+ scroll.scrollLeft = Math.max(0, playheadX - GUTTER);
100
+ }
101
+ });
102
+ return unsub;
103
+ });
104
+
105
+ const seekFromX = useCallback(
106
+ (clientX: number) => {
107
+ const el = scrollRef.current;
108
+ if (!el || effectiveDuration <= 0) return;
109
+ const rect = el.getBoundingClientRect();
110
+ const x = clientX - rect.left + el.scrollLeft - GUTTER;
111
+ if (x < 0) return;
112
+ const time = Math.max(0, Math.min(effectiveDuration, x / pps));
113
+ liveTime.notify(time);
114
+ onSeek?.(time);
115
+ },
116
+ [scrollRef, effectiveDuration, pps, onSeek],
117
+ );
118
+
119
+ const autoScrollDuringDrag = useCallback(
120
+ (clientX: number) => {
121
+ cancelAnimationFrame(dragScrollRaf.current);
122
+ const el = scrollRef.current;
123
+ if (
124
+ !el ||
125
+ !isDragging.current ||
126
+ !shouldAutoScrollTimeline(zoomModeRef.current, el.scrollWidth, el.clientWidth)
127
+ )
128
+ return;
129
+ const rect = el.getBoundingClientRect();
130
+ const edgeZone = 40;
131
+ const maxSpeed = 12;
132
+ let scrollDelta = 0;
133
+ if (clientX < rect.left + edgeZone)
134
+ scrollDelta = -maxSpeed * Math.max(0, 1 - (clientX - rect.left) / edgeZone);
135
+ else if (clientX > rect.right - edgeZone)
136
+ scrollDelta = maxSpeed * Math.max(0, 1 - (rect.right - clientX) / edgeZone);
137
+ if (scrollDelta !== 0) {
138
+ el.scrollLeft += scrollDelta;
139
+ seekFromX(clientX);
140
+ dragScrollRaf.current = requestAnimationFrame(() => autoScrollDuringDrag(clientX));
141
+ }
142
+ },
143
+ [scrollRef, isDragging, zoomModeRef, seekFromX],
144
+ );
145
+
146
+ const handlePinchWheel = useCallback(
147
+ (e: WheelEvent) => {
148
+ if (!e.ctrlKey) return;
149
+ const scroll = scrollRef.current;
150
+ if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0)
151
+ return;
152
+ e.preventDefault();
153
+ e.stopPropagation();
154
+ const rect = scroll.getBoundingClientRect();
155
+ const nextZoomPercent = getPinchTimelineZoomPercent(
156
+ e.deltaY,
157
+ zoomModeRef.current,
158
+ manualZoomPercentRef.current,
159
+ );
160
+ if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual")
161
+ return;
162
+ const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
163
+ const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
164
+ pointerX: e.clientX - rect.left,
165
+ currentScrollLeft: scroll.scrollLeft,
166
+ gutter: GUTTER,
167
+ currentPixelsPerSecond: ppsRef.current,
168
+ nextPixelsPerSecond: nextPps,
169
+ duration: durationRef.current,
170
+ });
171
+ setZoomMode("manual");
172
+ setManualZoomPercent(nextZoomPercent);
173
+ requestAnimationFrame(() => {
174
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
175
+ scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
176
+ });
177
+ },
178
+ [
179
+ scrollRef,
180
+ durationRef,
181
+ fitPpsRef,
182
+ ppsRef,
183
+ zoomModeRef,
184
+ manualZoomPercentRef,
185
+ setManualZoomPercent,
186
+ setZoomMode,
187
+ ],
188
+ );
189
+
190
+ useEffect(() => {
191
+ const scroll = scrollRef.current;
192
+ if (!scroll) return;
193
+ scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
194
+ return () => {
195
+ scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
196
+ };
197
+ }, [handlePinchWheel, scrollRef, timelineReady, elementsLength]);
198
+
199
+ return { seekFromX, autoScrollDuringDrag, dragScrollRaf };
200
+ }