@hyperframes/studio 0.7.86 → 0.7.87
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-BL3qfGEQ.js → hyperframes-player-BFqB5AWJ.js} +1 -1
- package/dist/assets/{index-B5QGai9B.js → index-B1Vf4vCl.js} +1 -1
- package/dist/assets/index-BXkzp_OU.css +1 -0
- package/dist/assets/{index-CISVI7UC.js → index-BblzZ6Av.js} +221 -221
- package/dist/assets/{index-C0Vr8jjc.js → index-D_JU4EeH.js} +1 -1
- package/dist/index.d.ts +14 -6
- package/dist/index.html +2 -2
- package/dist/index.js +6265 -4922
- package/dist/index.js.map +1 -1
- package/package.json +11 -8
- package/src/components/TimelineToolbar.tsx +14 -5
- package/src/components/nle/NLEContext.tsx +4 -1
- package/src/components/nle/TimelinePane.tsx +2 -0
- package/src/hooks/studioTestMode.test.ts +35 -0
- package/src/hooks/studioTestMode.ts +23 -0
- package/src/hooks/useGestureRecording.test.tsx +120 -0
- package/src/hooks/useGestureRecording.ts +49 -40
- package/src/hooks/useStudioTestHooks.test.tsx +161 -0
- package/src/hooks/useStudioTestHooks.ts +54 -7
- package/src/player/components/BeatStrip.tsx +21 -5
- package/src/player/components/CompositionThumbnail.test.ts +96 -2
- package/src/player/components/CompositionThumbnail.tsx +29 -16
- package/src/player/components/Timeline.test.ts +104 -0
- package/src/player/components/Timeline.tsx +141 -131
- package/src/player/components/Timeline.virtualization.test.tsx +400 -0
- package/src/player/components/TimelineCanvas.tsx +18 -10
- package/src/player/components/TimelineClip.tsx +4 -0
- package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineLanes.test.tsx +15 -4
- package/src/player/components/TimelineLanes.tsx +81 -26
- package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
- package/src/player/components/TimelinePropertyLanes.tsx +1 -0
- package/src/player/components/TimelineRuler.tsx +21 -5
- package/src/player/components/TimelineTrackRow.tsx +46 -0
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
- package/src/player/components/timelineDiamondTypes.ts +5 -0
- package/src/player/components/timelineDragDrop.ts +5 -5
- package/src/player/components/timelineEditing.test.ts +32 -0
- package/src/player/components/timelineEditing.ts +47 -5
- package/src/player/components/timelineFocusIdentity.test.ts +21 -0
- package/src/player/components/timelineFocusIdentity.ts +21 -0
- package/src/player/components/timelineLaneProps.ts +9 -0
- package/src/player/components/timelineLayout.test.ts +59 -0
- package/src/player/components/timelineLayout.ts +202 -176
- package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
- package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
- package/src/player/components/timelineRulerGeometry.ts +142 -0
- package/src/player/components/timelineViewModel.ts +44 -0
- package/src/player/components/timelineViewportGeometry.ts +51 -0
- package/src/player/components/timelineZoom.test.ts +42 -28
- package/src/player/components/timelineZoom.ts +42 -25
- package/src/player/components/useTimelineActiveClips.test.ts +82 -79
- package/src/player/components/useTimelineActiveClips.ts +49 -55
- package/src/player/components/useTimelineClipDrag.ts +40 -230
- package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
- package/src/player/components/useTimelineGeometry.ts +4 -11
- package/src/player/components/useTimelinePlayhead.ts +30 -14
- package/src/player/components/useTimelineRangeSelection.ts +5 -4
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
- package/src/player/components/useTimelineRevealClip.ts +171 -47
- package/src/player/components/useTimelineRowVirtualization.ts +140 -0
- package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
- package/src/player/components/useTimelineScrollViewport.ts +87 -9
- package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
- package/src/player/components/useTimelineShiftModifier.ts +20 -0
- package/src/player/components/useTimelineTicks.ts +22 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
- package/src/player/components/useTimelineTrackLayout.ts +35 -18
- package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
- package/src/player/components/useTimelineVirtualRows.ts +126 -0
- package/src/player/hooks/timelinePlayerSync.ts +20 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
- package/src/player/hooks/useTimelinePlayer.ts +8 -20
- package/src/player/lib/playbackScrub.ts +2 -2
- package/src/player/lib/timelineClipIndex.test.ts +169 -0
- package/src/player/lib/timelineClipIndex.ts +208 -0
- package/src/player/lib/timelineElementIndexes.test.ts +31 -0
- package/src/player/lib/timelineElementIndexes.ts +44 -0
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
- package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
- package/src/player/lib/timelinePerformanceFixture.ts +167 -0
- package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
- package/src/player/lib/timelineViewportBudgets.ts +151 -0
- package/src/player/store/playerStore.test.ts +20 -3
- package/src/player/store/playerStore.ts +73 -45
- package/src/utils/studioHelpers.test.ts +48 -1
- package/src/utils/studioHelpers.ts +31 -34
- package/src/utils/studioUiPreferences.ts +3 -1
- package/dist/assets/index-D379YOKT.css +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useLayoutEffect, useRef } from "react";
|
|
2
|
-
import { liveTime } from "../store/playerStore";
|
|
2
|
+
import { liveTime, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
4
|
|
|
5
5
|
interface ActiveClipRecord {
|
|
@@ -13,7 +13,34 @@ interface ActiveClipRecord {
|
|
|
13
13
|
interface UseTimelineActiveClipsInput {
|
|
14
14
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
15
15
|
currentTime: number;
|
|
16
|
-
clipStateVersion:
|
|
16
|
+
clipStateVersion: unknown;
|
|
17
|
+
elementStateVersion: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isTimelineIntervalActive(
|
|
21
|
+
interval: Pick<ActiveClipRecord, "start" | "end" | "hidden">,
|
|
22
|
+
time: number,
|
|
23
|
+
): boolean {
|
|
24
|
+
return (
|
|
25
|
+
Number.isFinite(time) &&
|
|
26
|
+
!interval.hidden &&
|
|
27
|
+
Number.isFinite(interval.start) &&
|
|
28
|
+
Number.isFinite(interval.end) &&
|
|
29
|
+
time >= interval.start &&
|
|
30
|
+
time <= interval.end
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Model-first active state. Rendered nodes receive this on their first mount. */
|
|
35
|
+
export function isTimelineClipActive(element: TimelineElement, time: number): boolean {
|
|
36
|
+
return isTimelineIntervalActive(
|
|
37
|
+
{
|
|
38
|
+
start: element.start,
|
|
39
|
+
end: element.start + Math.max(0, element.duration),
|
|
40
|
+
hidden: element.hidden === true,
|
|
41
|
+
},
|
|
42
|
+
time,
|
|
43
|
+
);
|
|
17
44
|
}
|
|
18
45
|
|
|
19
46
|
function readFiniteNumber(value: string | undefined): number | null {
|
|
@@ -27,66 +54,37 @@ function readClipRecord(element: Element): ActiveClipRecord | null {
|
|
|
27
54
|
const id = element.dataset.elId;
|
|
28
55
|
const start = readFiniteNumber(element.dataset.clipStart);
|
|
29
56
|
const end = readFiniteNumber(element.dataset.clipEnd);
|
|
30
|
-
const hidden = element.dataset.clipHidden === "true";
|
|
31
57
|
if (!id || start === null || end === null) return null;
|
|
32
|
-
return { id, start, end, hidden, element };
|
|
58
|
+
return { id, start, end, hidden: element.dataset.clipHidden === "true", element };
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
function collectTimelineClipRecords(container: HTMLElement): ActiveClipRecord[] {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (record) records.push(record);
|
|
40
|
-
}
|
|
41
|
-
return records;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function indexClipRecordsById(records: ActiveClipRecord[]): Map<string, ActiveClipRecord> {
|
|
45
|
-
const recordsById = new Map<string, ActiveClipRecord>();
|
|
46
|
-
for (const record of records) recordsById.set(record.id, record);
|
|
47
|
-
return recordsById;
|
|
62
|
+
return [...container.querySelectorAll('[data-clip="true"]')]
|
|
63
|
+
.map(readClipRecord)
|
|
64
|
+
.filter((record): record is ActiveClipRecord => record !== null);
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
function getActiveClipIds(records: ActiveClipRecord[], time: number): Set<string> {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (record.hidden) continue;
|
|
55
|
-
if (time >= record.start && time <= record.end) next.add(record.id);
|
|
56
|
-
}
|
|
57
|
-
return next;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function setsMatch(left: Set<string>, right: Set<string>): boolean {
|
|
61
|
-
if (left.size !== right.size) return false;
|
|
62
|
-
for (const value of left) {
|
|
63
|
-
if (!right.has(value)) return false;
|
|
64
|
-
}
|
|
65
|
-
return true;
|
|
68
|
+
return new Set(
|
|
69
|
+
records.filter((record) => isTimelineIntervalActive(record, time)).map((record) => record.id),
|
|
70
|
+
);
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
function applyActiveClipDiff(
|
|
69
74
|
records: ActiveClipRecord[],
|
|
70
75
|
previous: Set<string>,
|
|
71
76
|
time: number,
|
|
72
|
-
// Force every record's attribute to match its active state instead of only
|
|
73
|
-
// touching clips whose active-state changed. Required whenever `records` were
|
|
74
|
-
// freshly re-queried after a render: a clip that stayed active but got a new
|
|
75
|
-
// DOM node (e.g. moved lanes on a reorder) would otherwise be skipped by the
|
|
76
|
-
// diff and render without `data-active` despite still being at the playhead.
|
|
77
77
|
syncAll = false,
|
|
78
|
-
) {
|
|
78
|
+
): void {
|
|
79
79
|
const next = getActiveClipIds(records, time);
|
|
80
|
-
const changed = !setsMatch(previous, next);
|
|
81
80
|
for (const record of records) {
|
|
82
|
-
const wasActive = previous.has(record.id);
|
|
83
81
|
const isActive = next.has(record.id);
|
|
84
|
-
if (
|
|
85
|
-
|
|
82
|
+
if (syncAll || previous.has(record.id) !== isActive) {
|
|
83
|
+
record.element.toggleAttribute("data-active", isActive);
|
|
84
|
+
}
|
|
86
85
|
}
|
|
87
86
|
previous.clear();
|
|
88
87
|
for (const id of next) previous.add(id);
|
|
89
|
-
return changed;
|
|
90
88
|
}
|
|
91
89
|
|
|
92
90
|
export function updateTimelineActiveClipClasses(
|
|
@@ -94,30 +92,28 @@ export function updateTimelineActiveClipClasses(
|
|
|
94
92
|
previous: Set<string>,
|
|
95
93
|
time: number,
|
|
96
94
|
syncAll = false,
|
|
97
|
-
) {
|
|
95
|
+
): void {
|
|
98
96
|
applyActiveClipDiff(collectTimelineClipRecords(container), previous, time, syncAll);
|
|
99
97
|
}
|
|
100
98
|
|
|
99
|
+
/** Keeps the currently mounted clip window synchronized with the RAF playback clock. */
|
|
101
100
|
export function useTimelineActiveClips({
|
|
102
101
|
scrollRef,
|
|
103
102
|
currentTime,
|
|
104
103
|
clipStateVersion,
|
|
105
|
-
|
|
104
|
+
elementStateVersion,
|
|
105
|
+
}: UseTimelineActiveClipsInput): void {
|
|
106
106
|
const recordsRef = useRef<ActiveClipRecord[]>([]);
|
|
107
|
-
const recordsByIdRef = useRef(new Map<string, ActiveClipRecord>());
|
|
108
107
|
const previousActiveIdsRef = useRef(new Set<string>());
|
|
109
|
-
|
|
110
108
|
const refreshRecords = useCallback(
|
|
111
109
|
(time: number) => {
|
|
112
110
|
const scroll = scrollRef.current;
|
|
113
111
|
if (!scroll) {
|
|
114
112
|
recordsRef.current = [];
|
|
115
|
-
recordsByIdRef.current.clear();
|
|
116
113
|
previousActiveIdsRef.current.clear();
|
|
117
114
|
return;
|
|
118
115
|
}
|
|
119
116
|
recordsRef.current = collectTimelineClipRecords(scroll);
|
|
120
|
-
recordsByIdRef.current = indexClipRecordsById(recordsRef.current);
|
|
121
117
|
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time, true);
|
|
122
118
|
},
|
|
123
119
|
[scrollRef],
|
|
@@ -125,12 +121,10 @@ export function useTimelineActiveClips({
|
|
|
125
121
|
|
|
126
122
|
useLayoutEffect(() => {
|
|
127
123
|
refreshRecords(currentTime);
|
|
128
|
-
}, [currentTime,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const unsub = liveTime.subscribe((time) => {
|
|
124
|
+
}, [clipStateVersion, currentTime, elementStateVersion, refreshRecords]);
|
|
125
|
+
useMountEffect(() =>
|
|
126
|
+
liveTime.subscribe((time) => {
|
|
132
127
|
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
});
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
136
130
|
}
|
|
@@ -3,18 +3,15 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
3
3
|
import {
|
|
4
4
|
applyTimelineAutoScrollStep,
|
|
5
5
|
resolveTimelineAutoScrollLoopAction,
|
|
6
|
-
resolveTimelineDragEscape,
|
|
7
6
|
} from "./timelineEditing";
|
|
8
7
|
import { usePlayerStore } from "../store/playerStore";
|
|
9
8
|
import type { TimelineElement } from "../store/playerStore";
|
|
10
|
-
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
11
9
|
import { mergeUserBeats } from "../../utils/beatEditing";
|
|
12
10
|
import {
|
|
13
11
|
buildTimelineGroupResizeMembers,
|
|
14
12
|
type TimelineGroupResizeSession,
|
|
15
13
|
} from "./timelineGroupEditing";
|
|
16
14
|
import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineSnapping";
|
|
17
|
-
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
|
18
15
|
import type { StackingPatch } from "./timelineStackingSync";
|
|
19
16
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
20
17
|
import {
|
|
@@ -28,11 +25,9 @@ import type {
|
|
|
28
25
|
ResizingClipState,
|
|
29
26
|
BlockedClipState,
|
|
30
27
|
} from "./timelineClipDragTypes";
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} from "./timelineOptimisticRevision";
|
|
35
|
-
import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
|
|
28
|
+
import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle";
|
|
29
|
+
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
30
|
+
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
36
31
|
|
|
37
32
|
export type {
|
|
38
33
|
DraggedClipState,
|
|
@@ -48,7 +43,7 @@ interface UseTimelineClipDragInput {
|
|
|
48
43
|
ppsRef: React.RefObject<number>;
|
|
49
44
|
durationRef: React.RefObject<number>;
|
|
50
45
|
trackOrderRef: React.RefObject<number[]>;
|
|
51
|
-
|
|
46
|
+
rowGeometryRef?: React.RefObject<TimelineRowGeometry>;
|
|
52
47
|
onMoveElement?: (
|
|
53
48
|
element: TimelineElement,
|
|
54
49
|
updates: Pick<TimelineElement, "start" | "track">,
|
|
@@ -86,7 +81,7 @@ export function useTimelineClipDrag({
|
|
|
86
81
|
ppsRef,
|
|
87
82
|
durationRef,
|
|
88
83
|
trackOrderRef,
|
|
89
|
-
|
|
84
|
+
rowGeometryRef,
|
|
90
85
|
onMoveElement,
|
|
91
86
|
onMoveElements,
|
|
92
87
|
onResizeElement,
|
|
@@ -102,12 +97,11 @@ export function useTimelineClipDrag({
|
|
|
102
97
|
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
|
|
103
98
|
const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
|
|
104
99
|
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
const musicSrc = usePlayerStore((s) => s.elements.find(isMusicTrack)?.src ?? null);
|
|
100
|
+
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
|
|
101
|
+
const musicStart = musicElement?.start ?? 0;
|
|
102
|
+
const musicPlaybackStart = musicElement?.playbackStart ?? 0;
|
|
103
|
+
const musicDuration = musicElement?.duration ?? 0;
|
|
104
|
+
const musicSrc = musicElement?.src ?? null;
|
|
111
105
|
|
|
112
106
|
const adjustedBeatTimes = useMemo(() => {
|
|
113
107
|
if (rawBeatTimes === EMPTY_BEAT_TIMES || musicDuration === 0) return EMPTY_BEAT_TIMES;
|
|
@@ -234,23 +228,21 @@ export function useTimelineClipDrag({
|
|
|
234
228
|
// Build the audio-track set once per gesture (see snapTargetsCacheRef): it
|
|
235
229
|
// only feeds zone-aware drop placement and is frozen while dragging.
|
|
236
230
|
if (!dragAudioTracksRef.current) {
|
|
237
|
-
dragAudioTracksRef.current =
|
|
238
|
-
elementsRef.current.filter(isAudioTimelineElement).map((e) => e.track),
|
|
239
|
-
);
|
|
231
|
+
dragAudioTracksRef.current = getTimelineElementIndexes(elementsRef.current).audioTracks;
|
|
240
232
|
}
|
|
241
233
|
return computeDragPreview(drag, clientX, clientY, {
|
|
242
234
|
scroll: scrollRef.current,
|
|
243
235
|
pps: ppsRef.current,
|
|
244
236
|
duration: durationRef.current,
|
|
245
237
|
trackOrder: trackOrderRef.current,
|
|
246
|
-
rowHeights:
|
|
238
|
+
rowHeights: rowGeometryRef?.current.rowHeights,
|
|
247
239
|
elements: elementsRef.current,
|
|
248
240
|
selectedKeys: usePlayerStore.getState().selectedElementIds,
|
|
249
241
|
buildSnapTargets,
|
|
250
242
|
audioTracks: dragAudioTracksRef.current,
|
|
251
243
|
});
|
|
252
244
|
},
|
|
253
|
-
[scrollRef, ppsRef, durationRef, trackOrderRef,
|
|
245
|
+
[scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, buildSnapTargets],
|
|
254
246
|
);
|
|
255
247
|
|
|
256
248
|
// Recompute the trim preview for a pointer x. Shared by the pointermove resize
|
|
@@ -363,215 +355,33 @@ export function useTimelineClipDrag({
|
|
|
363
355
|
stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
|
|
364
356
|
|
|
365
357
|
useMountEffect(() => {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (!blocked.started && distance < threshold) return;
|
|
394
|
-
if (!blocked.started) {
|
|
395
|
-
blocked.started = true;
|
|
396
|
-
blockedClipRef.current = blocked;
|
|
397
|
-
suppressClickRef.current = true;
|
|
398
|
-
setShowPopover(false);
|
|
399
|
-
setRangeSelectionRef.current?.(null);
|
|
400
|
-
onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent);
|
|
401
|
-
}
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
const handleDragPointerMove = (e: PointerEvent, drag: DraggedClipState) => {
|
|
405
|
-
const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
|
|
406
|
-
if (!drag.started && distance < 4) return;
|
|
407
|
-
|
|
408
|
-
setShowPopover(false);
|
|
409
|
-
setRangeSelectionRef.current?.(null);
|
|
410
|
-
|
|
411
|
-
setDraggedClip((prev) =>
|
|
412
|
-
prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev,
|
|
413
|
-
);
|
|
414
|
-
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
|
|
415
|
-
};
|
|
416
|
-
|
|
417
|
-
const handleWindowPointerMove = (e: PointerEvent) => {
|
|
418
|
-
const resize = resizingClipRef.current;
|
|
419
|
-
if (resize) return handleResizePointerMove(e, resize);
|
|
420
|
-
const blocked = blockedClipRef.current;
|
|
421
|
-
if (blocked) return handleBlockedPointerMove(e, blocked);
|
|
422
|
-
const drag = draggedClipRef.current;
|
|
423
|
-
if (drag) handleDragPointerMove(e, drag);
|
|
424
|
-
};
|
|
425
|
-
|
|
426
|
-
/* ── pointerup commit handlers (dispatched by drag/resize/blocked) ──── */
|
|
427
|
-
const commitResizePointerUp = (resize: ResizingClipState) => {
|
|
428
|
-
resizingClipRef.current = null;
|
|
429
|
-
setResizingClip(null);
|
|
430
|
-
const groupSession = groupResizeRef.current;
|
|
431
|
-
groupResizeRef.current = null;
|
|
432
|
-
if (!resize.started) {
|
|
433
|
-
// No preview ran, so no group store-mutation to undo; guard is defensive.
|
|
434
|
-
if (groupSession) restoreGroupResizeMembers(groupSession);
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
suppressClickRef.current = true;
|
|
439
|
-
clearSuppressedClick();
|
|
440
|
-
|
|
441
|
-
if (groupSession) {
|
|
442
|
-
commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current);
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const hasChanged =
|
|
447
|
-
resize.previewStart !== resize.element.start ||
|
|
448
|
-
resize.previewDuration !== resize.element.duration ||
|
|
449
|
-
resize.previewPlaybackStart !== resize.element.playbackStart;
|
|
450
|
-
if (!hasChanged) return;
|
|
451
|
-
|
|
452
|
-
const resizeKey = resize.element.key ?? resize.element.id;
|
|
453
|
-
const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]);
|
|
454
|
-
updateElement(resizeKey, {
|
|
455
|
-
start: resize.previewStart,
|
|
456
|
-
duration: resize.previewDuration,
|
|
457
|
-
playbackStart: resize.previewPlaybackStart,
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
Promise.resolve(
|
|
461
|
-
onResizeElementRef.current?.(resize.element, {
|
|
462
|
-
start: resize.previewStart,
|
|
463
|
-
duration: resize.previewDuration,
|
|
464
|
-
playbackStart: resize.previewPlaybackStart,
|
|
465
|
-
}),
|
|
466
|
-
).catch((error) => {
|
|
467
|
-
rollbackLatestTimelineOptimisticGesture(updateElement, revision, [
|
|
468
|
-
{
|
|
469
|
-
key: resizeKey,
|
|
470
|
-
updates: {
|
|
471
|
-
start: resize.element.start,
|
|
472
|
-
duration: resize.element.duration,
|
|
473
|
-
playbackStart: resize.element.playbackStart,
|
|
474
|
-
},
|
|
475
|
-
},
|
|
476
|
-
]);
|
|
477
|
-
console.error("[Timeline] Failed to persist clip resize", error);
|
|
478
|
-
});
|
|
479
|
-
};
|
|
480
|
-
|
|
481
|
-
const finishBlockedPointerUp = (blocked: BlockedClipState) => {
|
|
482
|
-
blockedClipRef.current = null;
|
|
483
|
-
if (!blocked.started) return;
|
|
484
|
-
clearSuppressedClick();
|
|
485
|
-
};
|
|
486
|
-
|
|
487
|
-
const commitDragPointerUp = (drag: DraggedClipState) => {
|
|
488
|
-
draggedClipRef.current = null;
|
|
489
|
-
setDraggedClip(null);
|
|
490
|
-
if (!drag.started) return;
|
|
491
|
-
|
|
492
|
-
suppressClickRef.current = true;
|
|
493
|
-
clearSuppressedClick();
|
|
494
|
-
|
|
495
|
-
// Commit the drag — insert (new track), main-track ripple (reflow contiguous),
|
|
496
|
-
// a plain single-clip move, or a multi-selection move (every selected clip
|
|
497
|
-
// shifts by the dragged clip's time delta). See timelineClipDragCommit.
|
|
498
|
-
commitDraggedClipMove(drag, {
|
|
499
|
-
elements: elementsRef.current,
|
|
500
|
-
trackOrder: trackOrderRef.current,
|
|
501
|
-
updateElement,
|
|
502
|
-
onMoveElement: onMoveElementRef.current,
|
|
503
|
-
onMoveElements: onMoveElementsRef.current,
|
|
504
|
-
selectedKeys: usePlayerStore.getState().selectedElementIds,
|
|
505
|
-
// Lane ↔ stacking: engages only when the timeline layer provisions both
|
|
506
|
-
// deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync.
|
|
507
|
-
readZIndex: readZIndexRef.current,
|
|
508
|
-
onStackingPatches: onStackingPatchesRef.current,
|
|
509
|
-
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
|
510
|
-
});
|
|
511
|
-
};
|
|
512
|
-
|
|
513
|
-
const handleWindowPointerUp = () => {
|
|
514
|
-
stopClipDragAutoScrollRef.current();
|
|
515
|
-
|
|
516
|
-
const resize = resizingClipRef.current;
|
|
517
|
-
if (resize) return commitResizePointerUp(resize);
|
|
518
|
-
|
|
519
|
-
const blocked = blockedClipRef.current;
|
|
520
|
-
if (blocked) return finishBlockedPointerUp(blocked);
|
|
521
|
-
|
|
522
|
-
const drag = draggedClipRef.current;
|
|
523
|
-
if (!drag) {
|
|
524
|
-
// Escape-cancel leaves the click suppressor armed so the click this
|
|
525
|
-
// pointerup generates can't act on the clip; disarm it right after.
|
|
526
|
-
if (suppressClickRef.current) clearSuppressedClick();
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
commitDragPointerUp(drag);
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
// Escape cancels the in-progress gesture: no commit, no undo entry. The
|
|
533
|
-
// previews live only in the drag/resize state (the store is untouched
|
|
534
|
-
// until the pointerup commit), so clearing them restores the pre-drag
|
|
535
|
-
// timeline. Clip drags never take pointer capture (all tracking runs on
|
|
536
|
-
// these window listeners), so there is no capture to release; the null
|
|
537
|
-
// refs make the remaining pointermove/pointerup a no-op.
|
|
538
|
-
const handleWindowKeyDown = (e: KeyboardEvent) => {
|
|
539
|
-
const decision = resolveTimelineDragEscape({
|
|
540
|
-
key: e.key,
|
|
541
|
-
drag: draggedClipRef.current,
|
|
542
|
-
resize: resizingClipRef.current,
|
|
543
|
-
blocked: blockedClipRef.current,
|
|
544
|
-
});
|
|
545
|
-
if (!decision.cancel) return;
|
|
546
|
-
e.preventDefault();
|
|
547
|
-
e.stopPropagation();
|
|
548
|
-
stopClipDragAutoScrollRef.current();
|
|
549
|
-
draggedClipRef.current = null;
|
|
550
|
-
setDraggedClip(null);
|
|
551
|
-
resizingClipRef.current = null;
|
|
552
|
-
setResizingClip(null);
|
|
553
|
-
// Undo any group-resize preview store-mutation (non-grabbed members) so the
|
|
554
|
-
// cancelled gesture restores the pre-drag timeline, like the single-clip path.
|
|
555
|
-
const groupSession = groupResizeRef.current;
|
|
556
|
-
groupResizeRef.current = null;
|
|
557
|
-
if (groupSession) restoreGroupResizeMembers(groupSession);
|
|
558
|
-
blockedClipRef.current = null;
|
|
559
|
-
// The pointer is usually still down; keep the suppressor armed until the
|
|
560
|
-
// eventual pointerup (which disarms it) so its click can't reselect.
|
|
561
|
-
if (decision.suppressClick) suppressClickRef.current = true;
|
|
562
|
-
};
|
|
563
|
-
|
|
564
|
-
window.addEventListener("pointermove", handleWindowPointerMove);
|
|
565
|
-
window.addEventListener("pointerup", handleWindowPointerUp);
|
|
566
|
-
window.addEventListener("pointercancel", handleWindowPointerUp);
|
|
567
|
-
window.addEventListener("keydown", handleWindowKeyDown, true);
|
|
568
|
-
return () => {
|
|
569
|
-
stopClipDragAutoScrollRef.current();
|
|
570
|
-
window.removeEventListener("pointermove", handleWindowPointerMove);
|
|
571
|
-
window.removeEventListener("pointerup", handleWindowPointerUp);
|
|
572
|
-
window.removeEventListener("pointercancel", handleWindowPointerUp);
|
|
573
|
-
window.removeEventListener("keydown", handleWindowKeyDown, true);
|
|
574
|
-
};
|
|
358
|
+
return mountTimelineClipDragGestureLifecycle({
|
|
359
|
+
draggedClipRef,
|
|
360
|
+
resizingClipRef,
|
|
361
|
+
blockedClipRef,
|
|
362
|
+
groupResizeRef,
|
|
363
|
+
suppressClickRef,
|
|
364
|
+
elementsRef,
|
|
365
|
+
trackOrderRef,
|
|
366
|
+
setDraggedClip,
|
|
367
|
+
setResizingClip,
|
|
368
|
+
setShowPopover,
|
|
369
|
+
setRangeSelectionRef,
|
|
370
|
+
applyResizePointerRef,
|
|
371
|
+
syncClipDragAutoScrollRef,
|
|
372
|
+
stopClipDragAutoScrollRef,
|
|
373
|
+
updateDraggedClipPreviewRef,
|
|
374
|
+
restoreGroupResizeMembers,
|
|
375
|
+
updateElement,
|
|
376
|
+
onMoveElementRef,
|
|
377
|
+
onMoveElementsRef,
|
|
378
|
+
onResizeElementRef,
|
|
379
|
+
onResizeElementsRef,
|
|
380
|
+
onBlockedEditAttemptRef,
|
|
381
|
+
readZIndexRef,
|
|
382
|
+
onStackingPatchesRef,
|
|
383
|
+
refreshAfterLaneMoveRef,
|
|
384
|
+
});
|
|
575
385
|
});
|
|
576
386
|
|
|
577
387
|
return {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useMemo, type RefObject } from "react";
|
|
2
|
+
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
|
|
5
|
+
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
6
|
+
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
|
7
|
+
import { useTimelineRevealClip } from "./useTimelineRevealClip";
|
|
8
|
+
|
|
9
|
+
interface UseTimelineClipRenderWindowInput {
|
|
10
|
+
tracks: Parameters<typeof createTimelineClipIndex>[0];
|
|
11
|
+
viewport: TimelineScrollViewportSnapshot;
|
|
12
|
+
pixelsPerSecond: number;
|
|
13
|
+
contentOrigin: number;
|
|
14
|
+
duration: number;
|
|
15
|
+
selectedElementId?: string;
|
|
16
|
+
draggedElementId?: string;
|
|
17
|
+
resizingElementId?: string;
|
|
18
|
+
revealElementId?: string;
|
|
19
|
+
focusedEaseElementId?: string;
|
|
20
|
+
clipContextMenuElementId?: string;
|
|
21
|
+
keyframeContextMenuElementId?: string;
|
|
22
|
+
focusedElementId?: string;
|
|
23
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
24
|
+
elements: readonly TimelineElement[];
|
|
25
|
+
rowGeometry: TimelineRowGeometry;
|
|
26
|
+
allowHorizontalReveal: boolean;
|
|
27
|
+
rowVirtualizationActive: boolean;
|
|
28
|
+
sessionEpoch: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useTimelineClipRenderWindow({
|
|
32
|
+
tracks,
|
|
33
|
+
viewport,
|
|
34
|
+
pixelsPerSecond,
|
|
35
|
+
contentOrigin,
|
|
36
|
+
duration,
|
|
37
|
+
selectedElementId,
|
|
38
|
+
draggedElementId,
|
|
39
|
+
resizingElementId,
|
|
40
|
+
revealElementId,
|
|
41
|
+
focusedEaseElementId,
|
|
42
|
+
clipContextMenuElementId,
|
|
43
|
+
keyframeContextMenuElementId,
|
|
44
|
+
focusedElementId,
|
|
45
|
+
scrollRef,
|
|
46
|
+
elements,
|
|
47
|
+
rowGeometry,
|
|
48
|
+
allowHorizontalReveal,
|
|
49
|
+
rowVirtualizationActive,
|
|
50
|
+
sessionEpoch,
|
|
51
|
+
}: UseTimelineClipRenderWindowInput) {
|
|
52
|
+
const clipIndex = useMemo(() => createTimelineClipIndex(tracks), [tracks]);
|
|
53
|
+
const renderTimeRange = useMemo(
|
|
54
|
+
() => getTimelineRenderTimeRange(viewport, pixelsPerSecond, contentOrigin, duration),
|
|
55
|
+
[contentOrigin, duration, pixelsPerSecond, viewport],
|
|
56
|
+
);
|
|
57
|
+
const pinnedClipIdentities = useMemo(
|
|
58
|
+
() =>
|
|
59
|
+
new Set(
|
|
60
|
+
[
|
|
61
|
+
selectedElementId,
|
|
62
|
+
draggedElementId,
|
|
63
|
+
resizingElementId,
|
|
64
|
+
revealElementId,
|
|
65
|
+
focusedEaseElementId,
|
|
66
|
+
clipContextMenuElementId,
|
|
67
|
+
keyframeContextMenuElementId,
|
|
68
|
+
focusedElementId,
|
|
69
|
+
].filter((identity): identity is string => identity !== undefined),
|
|
70
|
+
),
|
|
71
|
+
[
|
|
72
|
+
clipContextMenuElementId,
|
|
73
|
+
draggedElementId,
|
|
74
|
+
focusedEaseElementId,
|
|
75
|
+
focusedElementId,
|
|
76
|
+
keyframeContextMenuElementId,
|
|
77
|
+
resizingElementId,
|
|
78
|
+
revealElementId,
|
|
79
|
+
selectedElementId,
|
|
80
|
+
],
|
|
81
|
+
);
|
|
82
|
+
useTimelineRevealClip({
|
|
83
|
+
scrollRef,
|
|
84
|
+
elements,
|
|
85
|
+
rowGeometry,
|
|
86
|
+
pixelsPerSecond,
|
|
87
|
+
contentOrigin,
|
|
88
|
+
allowHorizontal: allowHorizontalReveal,
|
|
89
|
+
deferFocusUntilViewportUpdate: rowVirtualizationActive,
|
|
90
|
+
focusedElementId,
|
|
91
|
+
viewportVersion: viewport,
|
|
92
|
+
sessionEpoch,
|
|
93
|
+
});
|
|
94
|
+
return { clipIndex, renderTimeRange, pinnedClipIdentities };
|
|
95
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useRef, type RefObject } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement, type ZoomMode } from "../store/playerStore";
|
|
3
3
|
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
4
4
|
import {
|
|
@@ -78,13 +78,6 @@ export function useTimelineGeometry({
|
|
|
78
78
|
resizeGhostEndPx,
|
|
79
79
|
});
|
|
80
80
|
const displayDuration = pps > 0 ? displayContentWidth / pps : effectiveDuration;
|
|
81
|
-
const clipStateVersion = useMemo(
|
|
82
|
-
() =>
|
|
83
|
-
expandedElements
|
|
84
|
-
.map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`)
|
|
85
|
-
.join("|"),
|
|
86
|
-
[expandedElements],
|
|
87
|
-
);
|
|
88
81
|
const zoomModeRef = useRef(zoomMode);
|
|
89
82
|
zoomModeRef.current = zoomMode;
|
|
90
83
|
const manualZoomPercentRef = useRef(manualZoomPercent);
|
|
@@ -92,7 +85,7 @@ export function useTimelineGeometry({
|
|
|
92
85
|
fitPpsRef.current = fitPps;
|
|
93
86
|
|
|
94
87
|
// Restore the horizontal scroll offset after an edit re-derives the elements
|
|
95
|
-
// (
|
|
88
|
+
// (the immutable element snapshot changes) so the reload doesn't jump the view. Only in manual
|
|
96
89
|
// (pinned) mode — fit mode hides the x-scrollbar (scrollLeft is always 0) — and
|
|
97
90
|
// never mid-drag (auto-scroll owns the offset then). rAF waits for the new layout
|
|
98
91
|
// so the clamp reads the post-resync scrollWidth. zoomMode is a legitimate dep:
|
|
@@ -109,7 +102,7 @@ export function useTimelineGeometry({
|
|
|
109
102
|
});
|
|
110
103
|
return () => cancelAnimationFrame(raf);
|
|
111
104
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
112
|
-
}, [
|
|
105
|
+
}, [expandedElements, zoomMode]);
|
|
113
106
|
// Publish the live scale so edit handlers OUTSIDE <Timeline> (the keyboard-delete
|
|
114
107
|
// path) can pin the zoom via pinTimelineZoomToCurrent without threading geometry.
|
|
115
108
|
// In a useEffect (not the render body) so React-18 concurrent replay — Suspense
|
|
@@ -125,7 +118,7 @@ export function useTimelineGeometry({
|
|
|
125
118
|
fitPps,
|
|
126
119
|
displayContentWidth,
|
|
127
120
|
displayDuration,
|
|
128
|
-
clipStateVersion,
|
|
121
|
+
clipStateVersion: expandedElements,
|
|
129
122
|
zoomModeRef,
|
|
130
123
|
manualZoomPercentRef,
|
|
131
124
|
};
|