@hyperframes/studio 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +2 -13
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/StudioPreviewArea.tsx +6 -2
- package/src/components/editor/DomEditOverlay.tsx +88 -1007
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1150
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEdits.ts +84 -1081
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +60 -144
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Timeline.tsx +189 -1418
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +69 -1372
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
- package/dist/assets/index-DUqUmaoH.js +0 -117
|
@@ -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
|
+
}
|