@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,14 +1,16 @@
|
|
|
1
1
|
import { useRef, useCallback, useEffect, useLayoutEffect } from "react";
|
|
2
|
-
import { liveTime, type ZoomMode } from "../store/playerStore";
|
|
2
|
+
import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
4
|
import { getPinchTimelineZoomPercent } from "./timelineZoom";
|
|
5
5
|
import {
|
|
6
|
+
getTimelinePlaybackFollowScrollLeft,
|
|
6
7
|
getTimelinePlayheadLeft,
|
|
7
8
|
getTimelineScrubTime,
|
|
8
9
|
getTimelineScrollLeftForZoomTransition,
|
|
9
10
|
getTimelineScrollLeftForZoomAnchor,
|
|
10
11
|
shouldAutoScrollTimeline,
|
|
11
12
|
} from "./timelineLayout";
|
|
13
|
+
import { applyTimelineHorizontalAutoScrollStep } from "./timelineEditing";
|
|
12
14
|
|
|
13
15
|
interface UseTimelinePlayheadInput {
|
|
14
16
|
playheadRef: React.RefObject<HTMLDivElement | null>;
|
|
@@ -121,9 +123,31 @@ export function useTimelinePlayhead({
|
|
|
121
123
|
useMountEffect(() => {
|
|
122
124
|
const unsub = liveTime.subscribe((t) => {
|
|
123
125
|
if (!playheadRef.current || durationRef.current <= 0) return;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current;
|
|
127
|
+
playheadRef.current.style.left = `${getTimelinePlayheadLeft(
|
|
128
|
+
t,
|
|
129
|
+
ppsRef.current,
|
|
130
|
+
contentOriginRef.current,
|
|
131
|
+
)}px`;
|
|
132
|
+
const scroll = scrollRef.current;
|
|
133
|
+
if (
|
|
134
|
+
!scroll ||
|
|
135
|
+
!usePlayerStore.getState().isPlaying ||
|
|
136
|
+
isDragging.current ||
|
|
137
|
+
zoomModeRef.current === "fit"
|
|
138
|
+
) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const nextScrollLeft = getTimelinePlaybackFollowScrollLeft({
|
|
142
|
+
playheadX,
|
|
143
|
+
currentScrollLeft: scroll.scrollLeft,
|
|
144
|
+
viewportWidth: scroll.clientWidth,
|
|
145
|
+
contentOrigin: contentOriginRef.current,
|
|
146
|
+
maxScrollLeft: scroll.scrollWidth - scroll.clientWidth,
|
|
147
|
+
});
|
|
148
|
+
if (Math.abs(nextScrollLeft - scroll.scrollLeft) >= 0.5) {
|
|
149
|
+
scroll.scrollLeft = nextScrollLeft;
|
|
150
|
+
}
|
|
127
151
|
});
|
|
128
152
|
return unsub;
|
|
129
153
|
});
|
|
@@ -157,16 +181,7 @@ export function useTimelinePlayhead({
|
|
|
157
181
|
!shouldAutoScrollTimeline(zoomModeRef.current, el.scrollWidth, el.clientWidth)
|
|
158
182
|
)
|
|
159
183
|
return;
|
|
160
|
-
|
|
161
|
-
const edgeZone = 40;
|
|
162
|
-
const maxSpeed = 12;
|
|
163
|
-
let scrollDelta = 0;
|
|
164
|
-
if (clientX < rect.left + edgeZone)
|
|
165
|
-
scrollDelta = -maxSpeed * Math.max(0, 1 - (clientX - rect.left) / edgeZone);
|
|
166
|
-
else if (clientX > rect.right - edgeZone)
|
|
167
|
-
scrollDelta = maxSpeed * Math.max(0, 1 - (rect.right - clientX) / edgeZone);
|
|
168
|
-
if (scrollDelta !== 0) {
|
|
169
|
-
el.scrollLeft += scrollDelta;
|
|
184
|
+
if (applyTimelineHorizontalAutoScrollStep(el, clientX)) {
|
|
170
185
|
seekFromX(clientX);
|
|
171
186
|
dragScrollRaf.current = requestAnimationFrame(() => autoScrollDuringDrag(clientX));
|
|
172
187
|
}
|
|
@@ -187,6 +202,7 @@ export function useTimelinePlayhead({
|
|
|
187
202
|
e.deltaY,
|
|
188
203
|
zoomModeRef.current,
|
|
189
204
|
manualZoomPercentRef.current,
|
|
205
|
+
fitPpsRef.current,
|
|
190
206
|
);
|
|
191
207
|
if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual")
|
|
192
208
|
return;
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type MarqueeClipInput,
|
|
17
17
|
} from "./timelineMarquee";
|
|
18
18
|
import type { Rect } from "../../utils/marqueeGeometry";
|
|
19
|
+
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
19
20
|
|
|
20
21
|
interface UseTimelineRangeSelectionInput {
|
|
21
22
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
@@ -30,7 +31,7 @@ interface UseTimelineRangeSelectionInput {
|
|
|
30
31
|
setShowPopover: (v: boolean) => void;
|
|
31
32
|
elementsRef: React.RefObject<TimelineElement[]>;
|
|
32
33
|
trackOrderRef: React.RefObject<number[]>;
|
|
33
|
-
|
|
34
|
+
rowGeometryRef: React.RefObject<TimelineRowGeometry>;
|
|
34
35
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
35
36
|
contentOrigin: number;
|
|
36
37
|
}
|
|
@@ -107,7 +108,7 @@ export function useTimelineRangeSelection({
|
|
|
107
108
|
setShowPopover,
|
|
108
109
|
elementsRef,
|
|
109
110
|
trackOrderRef,
|
|
110
|
-
|
|
111
|
+
rowGeometryRef,
|
|
111
112
|
onSelectElement,
|
|
112
113
|
contentOrigin,
|
|
113
114
|
}: UseTimelineRangeSelectionInput) {
|
|
@@ -176,12 +177,12 @@ export function useTimelineRangeSelection({
|
|
|
176
177
|
marquee,
|
|
177
178
|
elementsRef.current ?? [],
|
|
178
179
|
trackOrderRef.current ?? [],
|
|
179
|
-
|
|
180
|
+
rowGeometryRef.current.rowHeights,
|
|
180
181
|
ppsRef.current,
|
|
181
182
|
contentOrigin,
|
|
182
183
|
);
|
|
183
184
|
},
|
|
184
|
-
[toContentPoint, elementsRef, trackOrderRef,
|
|
185
|
+
[toContentPoint, elementsRef, trackOrderRef, rowGeometryRef, ppsRef, contentOrigin],
|
|
185
186
|
);
|
|
186
187
|
|
|
187
188
|
const stopMarqueeAutoScroll = useCallback(() => {
|
|
@@ -8,6 +8,7 @@ import React, { act } from "react";
|
|
|
8
8
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
9
|
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
10
10
|
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
|
11
|
+
import { getTimelineRowGeometry } from "./timelineLayout";
|
|
11
12
|
|
|
12
13
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
14
|
|
|
@@ -60,7 +61,7 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
|
|
|
60
61
|
const isDragging = { current: false };
|
|
61
62
|
const elementsRef = { current: [] };
|
|
62
63
|
const trackOrderRef = { current: [] };
|
|
63
|
-
const
|
|
64
|
+
const rowGeometryRef = { current: getTimelineRowGeometry([]) };
|
|
64
65
|
|
|
65
66
|
function Probe(): null {
|
|
66
67
|
latest = useTimelineRangeSelection({
|
|
@@ -75,7 +76,7 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
|
|
|
75
76
|
setShowPopover: vi.fn(),
|
|
76
77
|
elementsRef,
|
|
77
78
|
trackOrderRef,
|
|
78
|
-
|
|
79
|
+
rowGeometryRef,
|
|
79
80
|
contentOrigin: 0,
|
|
80
81
|
});
|
|
81
82
|
return null;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, useRef } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import { createTimelineRowGeometry } from "./timelineLayout";
|
|
9
|
+
import { useTimelineRevealClip } from "./useTimelineRevealClip";
|
|
10
|
+
|
|
11
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
12
|
+
|
|
13
|
+
const element: TimelineElement = {
|
|
14
|
+
id: "hero",
|
|
15
|
+
tag: "div",
|
|
16
|
+
start: 20,
|
|
17
|
+
duration: 2,
|
|
18
|
+
track: 1,
|
|
19
|
+
};
|
|
20
|
+
const geometry = createTimelineRowGeometry([1], [48]);
|
|
21
|
+
|
|
22
|
+
function createHarnessRoot() {
|
|
23
|
+
const host = document.createElement("div");
|
|
24
|
+
document.body.append(host);
|
|
25
|
+
return { host, root: createRoot(host) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface HarnessProps {
|
|
29
|
+
mounted: boolean;
|
|
30
|
+
version: number;
|
|
31
|
+
width?: number;
|
|
32
|
+
height?: number;
|
|
33
|
+
deferFocusUntilViewportUpdate?: boolean;
|
|
34
|
+
focusedElementId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Harness({
|
|
38
|
+
mounted,
|
|
39
|
+
version,
|
|
40
|
+
width = 300,
|
|
41
|
+
height = 100,
|
|
42
|
+
deferFocusUntilViewportUpdate = false,
|
|
43
|
+
focusedElementId,
|
|
44
|
+
}: HarnessProps) {
|
|
45
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
useTimelineRevealClip({
|
|
47
|
+
scrollRef,
|
|
48
|
+
elements: [element],
|
|
49
|
+
rowGeometry: geometry,
|
|
50
|
+
pixelsPerSecond: 100,
|
|
51
|
+
contentOrigin: 32,
|
|
52
|
+
allowHorizontal: true,
|
|
53
|
+
deferFocusUntilViewportUpdate,
|
|
54
|
+
focusedElementId,
|
|
55
|
+
viewportVersion: version,
|
|
56
|
+
sessionEpoch: 1,
|
|
57
|
+
});
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
ref={(node) => {
|
|
61
|
+
scrollRef.current = node;
|
|
62
|
+
if (node) {
|
|
63
|
+
Object.defineProperty(node, "clientWidth", { configurable: true, value: width });
|
|
64
|
+
Object.defineProperty(node, "clientHeight", { configurable: true, value: height });
|
|
65
|
+
}
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{mounted && <div data-el-id="hero" tabIndex={-1} />}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function renderHarness(root: Root, props: HarnessProps): Promise<void> {
|
|
74
|
+
await act(async () => root.render(<Harness {...props} />));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
usePlayerStore.getState().reset();
|
|
79
|
+
document.body.replaceChildren();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("useTimelineRevealClip", () => {
|
|
83
|
+
it("scrolls from model coordinates, then consumes only after the clip mounts", async () => {
|
|
84
|
+
const { host, root } = createHarnessRoot();
|
|
85
|
+
usePlayerStore.getState().requestClipReveal("hero");
|
|
86
|
+
|
|
87
|
+
await renderHarness(root, { mounted: false, version: 0 });
|
|
88
|
+
const scroll = host.firstElementChild as HTMLDivElement;
|
|
89
|
+
expect(scroll.scrollLeft).toBe(1_944);
|
|
90
|
+
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
|
|
91
|
+
|
|
92
|
+
await renderHarness(root, { mounted: true, version: 1 });
|
|
93
|
+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
|
94
|
+
expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero");
|
|
95
|
+
|
|
96
|
+
scroll.scrollLeft = 0;
|
|
97
|
+
await act(async () => usePlayerStore.getState().requestClipReveal("hero"));
|
|
98
|
+
await renderHarness(root, { mounted: true, version: 2 });
|
|
99
|
+
expect(scroll.scrollLeft).toBe(1_944);
|
|
100
|
+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
|
101
|
+
await act(async () => root.unmount());
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("consumes an invalid target without scrolling", async () => {
|
|
105
|
+
const { root } = createHarnessRoot();
|
|
106
|
+
usePlayerStore.getState().requestClipReveal("missing");
|
|
107
|
+
await renderHarness(root, { mounted: false, version: 0 });
|
|
108
|
+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
|
109
|
+
await act(async () => root.unmount());
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("keeps a reveal pending through zero-size viewport and virtualized focus handoff", async () => {
|
|
113
|
+
const { host, root } = createHarnessRoot();
|
|
114
|
+
usePlayerStore.getState().requestClipReveal("hero");
|
|
115
|
+
|
|
116
|
+
await renderHarness(root, { mounted: true, version: 0, width: 0, height: 0 });
|
|
117
|
+
const scroll = host.firstElementChild as HTMLDivElement;
|
|
118
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
119
|
+
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
|
|
120
|
+
expect(document.activeElement?.getAttribute("data-el-id")).not.toBe("hero");
|
|
121
|
+
|
|
122
|
+
await renderHarness(root, {
|
|
123
|
+
mounted: true,
|
|
124
|
+
version: 1,
|
|
125
|
+
deferFocusUntilViewportUpdate: true,
|
|
126
|
+
});
|
|
127
|
+
expect(scroll.scrollLeft).toBe(1_944);
|
|
128
|
+
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
|
|
129
|
+
await renderHarness(root, {
|
|
130
|
+
mounted: true,
|
|
131
|
+
version: 2,
|
|
132
|
+
deferFocusUntilViewportUpdate: true,
|
|
133
|
+
focusedElementId: "hero",
|
|
134
|
+
});
|
|
135
|
+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
|
136
|
+
expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero");
|
|
137
|
+
await act(async () => root.unmount());
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -1,56 +1,180 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* asset card / audio row) asks for a clip to be revealed, smooth-scroll the
|
|
4
|
-
* timeline's scroll container so that clip is visible — horizontally to its
|
|
5
|
-
* time and vertically to its lane.
|
|
6
|
-
*
|
|
7
|
-
* The request is consumed (cleared) whether or not the clip node is found, so
|
|
8
|
-
* a stale request can never replay a scroll later. Respects zoom mode: in
|
|
9
|
-
* "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
|
|
10
|
-
* only the vertical axis is scrolled there.
|
|
11
|
-
*/
|
|
12
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
13
3
|
import { usePlayerStore } from "../store/playerStore";
|
|
14
|
-
import {
|
|
4
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
5
|
+
import { CLIP_Y, RULER_H, type TimelineRowGeometry } from "./timelineLayout";
|
|
15
6
|
import { computeRevealScroll } from "./timelineRevealScroll";
|
|
16
7
|
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
interface UseTimelineRevealClipInput {
|
|
9
|
+
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
10
|
+
elements: readonly TimelineElement[];
|
|
11
|
+
rowGeometry: TimelineRowGeometry;
|
|
12
|
+
pixelsPerSecond: number;
|
|
13
|
+
contentOrigin: number;
|
|
14
|
+
allowHorizontal: boolean;
|
|
15
|
+
deferFocusUntilViewportUpdate: boolean;
|
|
16
|
+
focusedElementId?: string;
|
|
17
|
+
viewportVersion: unknown;
|
|
18
|
+
sessionEpoch: number;
|
|
19
|
+
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
function escapeSelectorValue(value: string): string {
|
|
22
|
+
return typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
23
|
+
? CSS.escape(value)
|
|
24
|
+
: value.replace(/["\\]/g, "\\$&");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function scrollToTimelineElement(
|
|
28
|
+
container: HTMLDivElement,
|
|
29
|
+
element: TimelineElement,
|
|
30
|
+
row: number,
|
|
31
|
+
rowGeometry: TimelineRowGeometry,
|
|
32
|
+
pixelsPerSecond: number,
|
|
33
|
+
contentOrigin: number,
|
|
34
|
+
allowHorizontal: boolean,
|
|
35
|
+
): boolean {
|
|
36
|
+
const clipLeft = contentOrigin + element.start * pixelsPerSecond;
|
|
37
|
+
const target = computeRevealScroll({
|
|
38
|
+
scrollLeft: container.scrollLeft,
|
|
39
|
+
scrollTop: container.scrollTop,
|
|
40
|
+
viewportWidth: container.clientWidth,
|
|
41
|
+
viewportHeight: container.clientHeight,
|
|
42
|
+
clipLeft,
|
|
43
|
+
clipRight: clipLeft + Math.max(element.duration * pixelsPerSecond, 4),
|
|
44
|
+
clipTop: rowGeometry.getRowTop(row) + CLIP_Y,
|
|
45
|
+
clipBottom: rowGeometry.getRowTop(row) + rowGeometry.getRowHeight(row) - CLIP_Y,
|
|
46
|
+
stickyLeft: contentOrigin,
|
|
47
|
+
stickyTop: RULER_H,
|
|
48
|
+
allowHorizontal,
|
|
49
|
+
});
|
|
50
|
+
if (target.left !== null) container.scrollLeft = target.left;
|
|
51
|
+
if (target.top !== null) container.scrollTop = target.top;
|
|
52
|
+
const didScroll = target.left !== null || target.top !== null;
|
|
53
|
+
if (didScroll) container.dispatchEvent(new Event("scroll"));
|
|
54
|
+
return didScroll;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function focusRevealedElement(container: HTMLDivElement, elementId: string): boolean {
|
|
58
|
+
const clip = container.querySelector(`[data-el-id="${escapeSelectorValue(elementId)}"]`);
|
|
59
|
+
if (!(clip instanceof HTMLElement)) return false;
|
|
60
|
+
const alreadyHighlighted = clip.hasAttribute("data-reveal-highlight");
|
|
61
|
+
clip.setAttribute("data-reveal-highlight", "true");
|
|
62
|
+
clip.focus({ preventScroll: true });
|
|
63
|
+
if (document.activeElement !== clip) {
|
|
64
|
+
clip.removeAttribute("data-reveal-highlight");
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (!alreadyHighlighted) {
|
|
68
|
+
clip.addEventListener("blur", () => clip.removeAttribute("data-reveal-highlight"), {
|
|
69
|
+
once: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function focusAndConsumeReveal(
|
|
76
|
+
container: HTMLDivElement,
|
|
77
|
+
request: { elementId: string; nonce: number },
|
|
78
|
+
deferUntilFocusPin: boolean,
|
|
79
|
+
focusedElementId?: string,
|
|
80
|
+
): void {
|
|
81
|
+
if (!focusRevealedElement(container, request.elementId)) return;
|
|
82
|
+
if (deferUntilFocusPin && focusedElementId !== request.elementId) return;
|
|
83
|
+
if (usePlayerStore.getState().clipRevealRequest === request) {
|
|
24
84
|
usePlayerStore.getState().clearClipRevealRequest();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
25
87
|
|
|
88
|
+
function resolveRevealTarget(
|
|
89
|
+
elements: readonly TimelineElement[],
|
|
90
|
+
rowGeometry: TimelineRowGeometry,
|
|
91
|
+
elementId: string,
|
|
92
|
+
): { element: TimelineElement; row: number } | null {
|
|
93
|
+
const element = elements.find((candidate) => getTimelineElementIdentity(candidate) === elementId);
|
|
94
|
+
if (!element) return null;
|
|
95
|
+
const row = rowGeometry.getRowIndex(element.track);
|
|
96
|
+
return row < 0 ? null : { element, row };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function shouldScrollReveal(
|
|
100
|
+
previous: { request: { elementId: string; nonce: number }; sessionEpoch: number } | null,
|
|
101
|
+
request: { elementId: string; nonce: number },
|
|
102
|
+
sessionEpoch: number,
|
|
103
|
+
): boolean {
|
|
104
|
+
return previous?.request !== request || previous.sessionEpoch !== sessionEpoch;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Coordinate-first reveal; the request remains pinned until its clip mounts. */
|
|
108
|
+
export function useTimelineRevealClip({
|
|
109
|
+
scrollRef,
|
|
110
|
+
elements,
|
|
111
|
+
rowGeometry,
|
|
112
|
+
pixelsPerSecond,
|
|
113
|
+
contentOrigin,
|
|
114
|
+
allowHorizontal,
|
|
115
|
+
deferFocusUntilViewportUpdate,
|
|
116
|
+
focusedElementId,
|
|
117
|
+
viewportVersion,
|
|
118
|
+
sessionEpoch,
|
|
119
|
+
}: UseTimelineRevealClipInput): void {
|
|
120
|
+
const revealRequest = usePlayerStore((state) => state.clipRevealRequest);
|
|
121
|
+
const scrolledRequestRef = useRef<{
|
|
122
|
+
request: { elementId: string; nonce: number };
|
|
123
|
+
sessionEpoch: number;
|
|
124
|
+
} | null>(null);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!revealRequest) {
|
|
128
|
+
scrolledRequestRef.current = null;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const target = resolveRevealTarget(elements, rowGeometry, revealRequest.elementId);
|
|
132
|
+
if (!target) {
|
|
133
|
+
usePlayerStore.getState().clearClipRevealRequest();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
26
136
|
const container = scrollRef.current;
|
|
27
137
|
if (!container) return;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
138
|
+
if (container.clientWidth <= 0 || container.clientHeight <= 0) return;
|
|
139
|
+
|
|
140
|
+
if (shouldScrollReveal(scrolledRequestRef.current, revealRequest, sessionEpoch)) {
|
|
141
|
+
scrolledRequestRef.current = { request: revealRequest, sessionEpoch };
|
|
142
|
+
const didScroll = scrollToTimelineElement(
|
|
143
|
+
container,
|
|
144
|
+
target.element,
|
|
145
|
+
target.row,
|
|
146
|
+
rowGeometry,
|
|
147
|
+
pixelsPerSecond,
|
|
148
|
+
contentOrigin,
|
|
149
|
+
allowHorizontal,
|
|
150
|
+
);
|
|
151
|
+
// Keep the reveal pin alive until the scroll snapshot catches up. If the
|
|
152
|
+
// request were consumed here, horizontal windowing could unmount and
|
|
153
|
+
// recreate the focused clip between the programmatic scroll and the next
|
|
154
|
+
// viewport publication.
|
|
155
|
+
if (didScroll && deferFocusUntilViewportUpdate) return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Focus is the durable row/clip pin. Do not consume the reveal pin until
|
|
159
|
+
// the focus listener has published that replacement, or windowing can
|
|
160
|
+
// briefly unmount and recreate the element between the two owners.
|
|
161
|
+
focusAndConsumeReveal(
|
|
162
|
+
container,
|
|
163
|
+
revealRequest,
|
|
164
|
+
deferFocusUntilViewportUpdate,
|
|
165
|
+
focusedElementId,
|
|
166
|
+
);
|
|
167
|
+
}, [
|
|
168
|
+
allowHorizontal,
|
|
169
|
+
contentOrigin,
|
|
170
|
+
deferFocusUntilViewportUpdate,
|
|
171
|
+
elements,
|
|
172
|
+
focusedElementId,
|
|
173
|
+
pixelsPerSecond,
|
|
174
|
+
revealRequest,
|
|
175
|
+
rowGeometry,
|
|
176
|
+
scrollRef,
|
|
177
|
+
sessionEpoch,
|
|
178
|
+
viewportVersion,
|
|
179
|
+
]);
|
|
56
180
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type FocusEvent as ReactFocusEvent,
|
|
8
|
+
type RefObject,
|
|
9
|
+
} from "react";
|
|
10
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
11
|
+
import { resolveTimelineFocusIdentity } from "./timelineFocusIdentity";
|
|
12
|
+
import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry";
|
|
13
|
+
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
14
|
+
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
|
15
|
+
import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag";
|
|
16
|
+
import { useTimelineVirtualRows } from "./useTimelineVirtualRows";
|
|
17
|
+
|
|
18
|
+
interface UseTimelineRowVirtualizationInput {
|
|
19
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
20
|
+
viewport: TimelineScrollViewportSnapshot;
|
|
21
|
+
rowGeometry: TimelineRowGeometry;
|
|
22
|
+
sessionEpoch: number;
|
|
23
|
+
elements: TimelineElement[];
|
|
24
|
+
selectedElementId: string | null;
|
|
25
|
+
revealElementId: string | null;
|
|
26
|
+
draggedRowKey?: number;
|
|
27
|
+
resizingRowKey?: number;
|
|
28
|
+
clipContextMenuRowKey?: number;
|
|
29
|
+
keyframeContextMenuRowKey?: number;
|
|
30
|
+
lastScrollLeftRef: RefObject<number>;
|
|
31
|
+
syncScrollViewport: (element: HTMLDivElement, isScrolling?: boolean) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TimelineDomFocusPin {
|
|
35
|
+
readonly rowKey?: number;
|
|
36
|
+
readonly elementId?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getTimelineDomFocusPin(target: EventTarget | null): TimelineDomFocusPin | undefined {
|
|
40
|
+
if (!(target instanceof Element)) return undefined;
|
|
41
|
+
const value = target.closest<HTMLElement>("[data-timeline-row-key]")?.dataset.timelineRowKey;
|
|
42
|
+
const parsedRowKey = value === undefined ? undefined : Number(value);
|
|
43
|
+
const rowKey =
|
|
44
|
+
parsedRowKey !== undefined && Number.isFinite(parsedRowKey) ? parsedRowKey : undefined;
|
|
45
|
+
const elementId = target.closest<HTMLElement>("[data-el-id]")?.dataset.elId;
|
|
46
|
+
return rowKey === undefined && elementId === undefined ? undefined : { rowKey, elementId };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useTimelineRowVirtualization({
|
|
50
|
+
scrollRef,
|
|
51
|
+
viewport,
|
|
52
|
+
rowGeometry,
|
|
53
|
+
sessionEpoch,
|
|
54
|
+
elements,
|
|
55
|
+
selectedElementId,
|
|
56
|
+
revealElementId,
|
|
57
|
+
draggedRowKey,
|
|
58
|
+
resizingRowKey,
|
|
59
|
+
clipContextMenuRowKey,
|
|
60
|
+
keyframeContextMenuRowKey,
|
|
61
|
+
lastScrollLeftRef,
|
|
62
|
+
syncScrollViewport,
|
|
63
|
+
}: UseTimelineRowVirtualizationInput) {
|
|
64
|
+
const enabled = STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED;
|
|
65
|
+
const [domFocusPin, setDomFocusPin] = useState<TimelineDomFocusPin>();
|
|
66
|
+
const onTimelineFocus = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
|
|
67
|
+
setDomFocusPin(getTimelineDomFocusPin(event.target));
|
|
68
|
+
}, []);
|
|
69
|
+
const onTimelineBlur = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
|
|
70
|
+
setDomFocusPin(getTimelineDomFocusPin(event.relatedTarget));
|
|
71
|
+
}, []);
|
|
72
|
+
const focusIdentity = useMemo(
|
|
73
|
+
() => resolveTimelineFocusIdentity(elements, selectedElementId),
|
|
74
|
+
[elements, selectedElementId],
|
|
75
|
+
);
|
|
76
|
+
const revealIdentity = useMemo(
|
|
77
|
+
() => resolveTimelineFocusIdentity(elements, revealElementId),
|
|
78
|
+
[elements, revealElementId],
|
|
79
|
+
);
|
|
80
|
+
const pinnedRowKeys = useMemo(
|
|
81
|
+
() =>
|
|
82
|
+
[
|
|
83
|
+
draggedRowKey,
|
|
84
|
+
resizingRowKey,
|
|
85
|
+
revealIdentity?.rowKey,
|
|
86
|
+
clipContextMenuRowKey,
|
|
87
|
+
keyframeContextMenuRowKey,
|
|
88
|
+
].filter((rowKey): rowKey is number => rowKey !== undefined),
|
|
89
|
+
[
|
|
90
|
+
clipContextMenuRowKey,
|
|
91
|
+
draggedRowKey,
|
|
92
|
+
keyframeContextMenuRowKey,
|
|
93
|
+
resizingRowKey,
|
|
94
|
+
revealIdentity,
|
|
95
|
+
],
|
|
96
|
+
);
|
|
97
|
+
const virtualRows = useTimelineVirtualRows({
|
|
98
|
+
enabled,
|
|
99
|
+
scrollRef,
|
|
100
|
+
viewport,
|
|
101
|
+
rowGeometry,
|
|
102
|
+
sessionEpoch,
|
|
103
|
+
pinnedRowKeys,
|
|
104
|
+
focusedRowKey: domFocusPin?.rowKey ?? focusIdentity?.rowKey,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const previousLayoutRef = useRef(rowGeometry);
|
|
108
|
+
const previousSessionEpochRef = useRef(sessionEpoch);
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
const scroll = scrollRef.current;
|
|
111
|
+
const previousGeometry = previousLayoutRef.current;
|
|
112
|
+
if (previousSessionEpochRef.current !== sessionEpoch) {
|
|
113
|
+
previousSessionEpochRef.current = sessionEpoch;
|
|
114
|
+
lastScrollLeftRef.current = 0;
|
|
115
|
+
if (scroll) {
|
|
116
|
+
scroll.scrollLeft = 0;
|
|
117
|
+
scroll.scrollTop = 0;
|
|
118
|
+
syncScrollViewport(scroll);
|
|
119
|
+
}
|
|
120
|
+
} else if (scroll && previousGeometry !== rowGeometry) {
|
|
121
|
+
const nextScrollTop = getTimelineScrollTopForGeometryChange(
|
|
122
|
+
previousGeometry,
|
|
123
|
+
rowGeometry,
|
|
124
|
+
scroll.scrollTop,
|
|
125
|
+
);
|
|
126
|
+
if (nextScrollTop !== scroll.scrollTop) {
|
|
127
|
+
scroll.scrollTop = nextScrollTop;
|
|
128
|
+
syncScrollViewport(scroll);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
previousLayoutRef.current = rowGeometry;
|
|
132
|
+
}, [lastScrollLeftRef, rowGeometry, scrollRef, sessionEpoch, syncScrollViewport]);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
enabled,
|
|
136
|
+
virtualRows,
|
|
137
|
+
focusedElementId: domFocusPin?.elementId,
|
|
138
|
+
timelineFocusProps: { onFocus: onTimelineFocus, onBlur: onTimelineBlur },
|
|
139
|
+
};
|
|
140
|
+
}
|