@hyperframes/studio 0.7.86 → 0.7.88
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-B03_wTTS.js} +1 -1
- package/dist/assets/{index-B5QGai9B.js → index-BNshnsvB.js} +1 -1
- package/dist/assets/index-BXkzp_OU.css +1 -0
- package/dist/assets/{index-C0Vr8jjc.js → index-D7KcL_t3.js} +1 -1
- package/dist/assets/{index-CISVI7UC.js → index-DbY124Po.js} +221 -221
- 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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, useRef } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
|
+
|
|
9
|
+
let resizeCallback: ResizeObserverCallback | null = null;
|
|
10
|
+
class MockResizeObserver {
|
|
11
|
+
constructor(callback: ResizeObserverCallback) {
|
|
12
|
+
resizeCallback = callback;
|
|
13
|
+
}
|
|
14
|
+
observe() {}
|
|
15
|
+
disconnect() {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.useFakeTimers();
|
|
22
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.useRealTimers();
|
|
27
|
+
vi.unstubAllEnvs();
|
|
28
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
29
|
+
resizeCallback = null;
|
|
30
|
+
document.body.innerHTML = "";
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
interface Harness {
|
|
34
|
+
hook: () => ReturnType<
|
|
35
|
+
typeof import("./useTimelineScrollViewport").useTimelineScrollViewport
|
|
36
|
+
> | null;
|
|
37
|
+
element: HTMLDivElement;
|
|
38
|
+
values: {
|
|
39
|
+
left: number;
|
|
40
|
+
top: number;
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
scrollWidth: number;
|
|
44
|
+
scrollHeight: number;
|
|
45
|
+
};
|
|
46
|
+
unmount: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The row-virtualization flag is a module constant, so the env stub has to be in
|
|
51
|
+
* place before the hook module is imported. Each harness therefore resets the
|
|
52
|
+
* module registry and imports fresh, which is what lets one file exercise both
|
|
53
|
+
* flag states.
|
|
54
|
+
*/
|
|
55
|
+
async function mountHarness(rowVirtualizationEnabled: boolean): Promise<Harness> {
|
|
56
|
+
vi.stubEnv(
|
|
57
|
+
"VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED",
|
|
58
|
+
rowVirtualizationEnabled ? "1" : "0",
|
|
59
|
+
);
|
|
60
|
+
vi.resetModules();
|
|
61
|
+
const { useTimelineScrollViewport } = await import("./useTimelineScrollViewport");
|
|
62
|
+
|
|
63
|
+
let current: ReturnType<typeof useTimelineScrollViewport> | null = null;
|
|
64
|
+
function Probe() {
|
|
65
|
+
current = useTimelineScrollViewport(useRef<HTMLDivElement>(null), []);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const root = createRoot(document.createElement("div"));
|
|
70
|
+
act(() => root.render(React.createElement(Probe)));
|
|
71
|
+
|
|
72
|
+
const element = document.createElement("div");
|
|
73
|
+
const values = {
|
|
74
|
+
left: 0,
|
|
75
|
+
top: 0,
|
|
76
|
+
width: 640,
|
|
77
|
+
height: 240,
|
|
78
|
+
scrollWidth: 1200,
|
|
79
|
+
scrollHeight: 800,
|
|
80
|
+
};
|
|
81
|
+
Object.defineProperties(element, {
|
|
82
|
+
scrollLeft: { configurable: true, get: () => values.left },
|
|
83
|
+
scrollTop: { configurable: true, get: () => values.top },
|
|
84
|
+
clientWidth: { configurable: true, get: () => values.width },
|
|
85
|
+
clientHeight: { configurable: true, get: () => values.height },
|
|
86
|
+
scrollWidth: { configurable: true, get: () => values.scrollWidth },
|
|
87
|
+
scrollHeight: { configurable: true, get: () => values.scrollHeight },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return { hook: () => current, element, values, unmount: () => act(() => root.unmount()) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function attachScrollElement({ hook, element }: Harness): void {
|
|
94
|
+
act(() => hook()?.setScrollRef(element));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function publishResize(harness: Harness, width: number): void {
|
|
98
|
+
harness.values.width = width;
|
|
99
|
+
act(() => resizeCallback?.([], {} as ResizeObserver));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function publishScroll(
|
|
103
|
+
harness: Harness,
|
|
104
|
+
{ left, top }: { left: number; top: number },
|
|
105
|
+
isScrolling = true,
|
|
106
|
+
): void {
|
|
107
|
+
harness.values.left = left;
|
|
108
|
+
harness.values.top = top;
|
|
109
|
+
act(() => harness.hook()?.syncScrollViewport(harness.element, isScrolling));
|
|
110
|
+
act(() => vi.advanceTimersByTime(16));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function expectResizePublication(harness: Harness): void {
|
|
114
|
+
attachScrollElement(harness);
|
|
115
|
+
expect(harness.hook()?.viewport.clientWidth).toBe(640);
|
|
116
|
+
|
|
117
|
+
publishResize(harness, 800);
|
|
118
|
+
expect(harness.hook()?.viewport.clientWidth).toBe(800);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe("useTimelineScrollViewport with row virtualization on", () => {
|
|
122
|
+
it("publishes resize, scroll, and settled snapshots", async () => {
|
|
123
|
+
const harness = await mountHarness(true);
|
|
124
|
+
const { hook, unmount } = harness;
|
|
125
|
+
|
|
126
|
+
expectResizePublication(harness);
|
|
127
|
+
|
|
128
|
+
publishScroll(harness, { left: 120, top: 48 });
|
|
129
|
+
expect(hook()?.viewport).toMatchObject({ scrollLeft: 120, scrollTop: 48, isScrolling: true });
|
|
130
|
+
|
|
131
|
+
act(() => vi.advanceTimersByTime(100));
|
|
132
|
+
expect(hook()?.viewport.isScrolling).toBe(false);
|
|
133
|
+
unmount();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("useTimelineScrollViewport with row virtualization off", () => {
|
|
138
|
+
it("publishes nothing while scrolling", async () => {
|
|
139
|
+
const harness = await mountHarness(false);
|
|
140
|
+
const { hook, unmount } = harness;
|
|
141
|
+
|
|
142
|
+
attachScrollElement(harness);
|
|
143
|
+
const before = hook()?.viewport;
|
|
144
|
+
|
|
145
|
+
publishScroll(harness, { left: 120, top: 48 });
|
|
146
|
+
|
|
147
|
+
expect(hook()?.viewport).toBe(before);
|
|
148
|
+
expect(hook()?.viewport.scrollLeft).toBe(0);
|
|
149
|
+
unmount();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("never reports isScrolling, so the settle timer has nothing to undo", async () => {
|
|
153
|
+
const harness = await mountHarness(false);
|
|
154
|
+
const { hook, unmount } = harness;
|
|
155
|
+
|
|
156
|
+
attachScrollElement(harness);
|
|
157
|
+
publishScroll(harness, { left: 0, top: 0 });
|
|
158
|
+
act(() => vi.advanceTimersByTime(500));
|
|
159
|
+
|
|
160
|
+
expect(hook()?.viewport.isScrolling).toBe(false);
|
|
161
|
+
unmount();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("schedules no frame or timer for a scroll sync", async () => {
|
|
165
|
+
const harness = await mountHarness(false);
|
|
166
|
+
const { hook, element, unmount } = harness;
|
|
167
|
+
attachScrollElement(harness);
|
|
168
|
+
|
|
169
|
+
const rafSpy = vi.spyOn(globalThis, "requestAnimationFrame");
|
|
170
|
+
const timerSpy = vi.spyOn(globalThis, "setTimeout");
|
|
171
|
+
act(() => hook()?.syncScrollViewport(element, true));
|
|
172
|
+
|
|
173
|
+
expect(rafSpy).not.toHaveBeenCalled();
|
|
174
|
+
expect(timerSpy).not.toHaveBeenCalled();
|
|
175
|
+
rafSpy.mockRestore();
|
|
176
|
+
timerSpy.mockRestore();
|
|
177
|
+
unmount();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("still publishes resize-driven snapshots", async () => {
|
|
181
|
+
const harness = await mountHarness(false);
|
|
182
|
+
const { unmount } = harness;
|
|
183
|
+
|
|
184
|
+
expectResizePublication(harness);
|
|
185
|
+
unmount();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("still publishes programmatic non-scrolling syncs", async () => {
|
|
189
|
+
const harness = await mountHarness(false);
|
|
190
|
+
const { hook, unmount } = harness;
|
|
191
|
+
|
|
192
|
+
attachScrollElement(harness);
|
|
193
|
+
publishScroll(harness, { left: 240, top: 96 }, false);
|
|
194
|
+
|
|
195
|
+
expect(hook()?.viewport).toMatchObject({
|
|
196
|
+
scrollLeft: 240,
|
|
197
|
+
scrollTop: 96,
|
|
198
|
+
isScrolling: false,
|
|
199
|
+
});
|
|
200
|
+
unmount();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { shouldShowTimelineShortcutHint } from "./timelineLayout";
|
|
4
|
+
import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag";
|
|
5
|
+
|
|
6
|
+
export interface TimelineScrollViewportSnapshot {
|
|
7
|
+
readonly scrollLeft: number;
|
|
8
|
+
readonly scrollTop: number;
|
|
9
|
+
readonly clientWidth: number;
|
|
10
|
+
readonly clientHeight: number;
|
|
11
|
+
readonly scrollWidth: number;
|
|
12
|
+
readonly scrollHeight: number;
|
|
13
|
+
readonly isScrolling: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EMPTY_VIEWPORT: TimelineScrollViewportSnapshot = Object.freeze({
|
|
17
|
+
scrollLeft: 0,
|
|
18
|
+
scrollTop: 0,
|
|
19
|
+
clientWidth: 0,
|
|
20
|
+
clientHeight: 0,
|
|
21
|
+
scrollWidth: 0,
|
|
22
|
+
scrollHeight: 0,
|
|
23
|
+
isScrolling: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function readTimelineScrollViewport(
|
|
27
|
+
element: Pick<
|
|
28
|
+
HTMLElement,
|
|
29
|
+
"scrollLeft" | "scrollTop" | "clientWidth" | "clientHeight" | "scrollWidth" | "scrollHeight"
|
|
30
|
+
>,
|
|
31
|
+
isScrolling: boolean,
|
|
32
|
+
): TimelineScrollViewportSnapshot {
|
|
33
|
+
return {
|
|
34
|
+
scrollLeft: element.scrollLeft,
|
|
35
|
+
scrollTop: element.scrollTop,
|
|
36
|
+
clientWidth: element.clientWidth,
|
|
37
|
+
clientHeight: element.clientHeight,
|
|
38
|
+
scrollWidth: element.scrollWidth,
|
|
39
|
+
scrollHeight: element.scrollHeight,
|
|
40
|
+
isScrolling,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
4
43
|
|
|
5
44
|
/**
|
|
6
45
|
* The timeline scroll container's viewport plumbing — extracted verbatim from
|
|
@@ -14,14 +53,46 @@ export function useTimelineScrollViewport(
|
|
|
14
53
|
scrollRef: RefObject<HTMLDivElement | null>,
|
|
15
54
|
resyncShortcutHintOn: ReadonlyArray<unknown>,
|
|
16
55
|
): {
|
|
17
|
-
|
|
56
|
+
viewport: TimelineScrollViewportSnapshot;
|
|
18
57
|
showShortcutHint: boolean;
|
|
19
58
|
setScrollRef: (el: HTMLDivElement | null) => void;
|
|
59
|
+
syncScrollViewport: (el: HTMLDivElement, isScrolling?: boolean) => void;
|
|
20
60
|
} {
|
|
21
|
-
const [
|
|
61
|
+
const [viewport, setViewport] = useState<TimelineScrollViewportSnapshot>(EMPTY_VIEWPORT);
|
|
22
62
|
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
|
23
63
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
24
64
|
const shortcutHintRafRef = useRef(0);
|
|
65
|
+
const viewportRafRef = useRef(0);
|
|
66
|
+
const scrollSettledTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
67
|
+
const scrollingRef = useRef(false);
|
|
68
|
+
|
|
69
|
+
const syncScrollViewport = useCallback((el: HTMLDivElement, isScrolling = false) => {
|
|
70
|
+
// Row virtualization is the only consumer of the per-frame scroll snapshot.
|
|
71
|
+
// With the flag off, publishing it re-rendered every mounted clip on every
|
|
72
|
+
// scroll frame and bought nothing, so the scroll path stops here and
|
|
73
|
+
// `isScrolling` stays false. Resize-driven and programmatic syncs arrive
|
|
74
|
+
// through the immediate path below and still publish.
|
|
75
|
+
if (isScrolling && !STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED) return;
|
|
76
|
+
scrollingRef.current = isScrolling;
|
|
77
|
+
const publish = () => {
|
|
78
|
+
viewportRafRef.current = 0;
|
|
79
|
+
setViewport(readTimelineScrollViewport(el, scrollingRef.current));
|
|
80
|
+
};
|
|
81
|
+
if (isScrolling) {
|
|
82
|
+
if (!viewportRafRef.current) viewportRafRef.current = requestAnimationFrame(publish);
|
|
83
|
+
} else {
|
|
84
|
+
if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
|
|
85
|
+
publish();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current);
|
|
89
|
+
scrollSettledTimerRef.current = setTimeout(() => {
|
|
90
|
+
scrollSettledTimerRef.current = null;
|
|
91
|
+
scrollingRef.current = false;
|
|
92
|
+
if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
|
|
93
|
+
publish();
|
|
94
|
+
}, 100);
|
|
95
|
+
}, []);
|
|
25
96
|
|
|
26
97
|
const syncShortcutHintVisibility = useCallback(() => {
|
|
27
98
|
const scroll = scrollRef.current;
|
|
@@ -45,23 +116,30 @@ export function useTimelineScrollViewport(
|
|
|
45
116
|
roRef.current = null;
|
|
46
117
|
}
|
|
47
118
|
scrollRef.current = el;
|
|
48
|
-
if (!el)
|
|
119
|
+
if (!el) {
|
|
120
|
+
if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current);
|
|
121
|
+
scrollSettledTimerRef.current = null;
|
|
122
|
+
scrollingRef.current = false;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
49
125
|
|
|
50
|
-
const
|
|
51
|
-
|
|
126
|
+
const syncResize = () => {
|
|
127
|
+
syncScrollViewport(el, scrollingRef.current);
|
|
52
128
|
scheduleShortcutHintVisibilitySync();
|
|
53
129
|
};
|
|
54
130
|
|
|
55
|
-
|
|
56
|
-
roRef.current = new ResizeObserver(
|
|
131
|
+
syncResize();
|
|
132
|
+
roRef.current = new ResizeObserver(syncResize);
|
|
57
133
|
roRef.current.observe(el);
|
|
58
134
|
},
|
|
59
|
-
[scrollRef, scheduleShortcutHintVisibilitySync],
|
|
135
|
+
[scrollRef, scheduleShortcutHintVisibilitySync, syncScrollViewport],
|
|
60
136
|
);
|
|
61
137
|
|
|
62
138
|
useMountEffect(() => () => {
|
|
63
139
|
roRef.current?.disconnect();
|
|
64
140
|
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
141
|
+
if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
|
|
142
|
+
if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current);
|
|
65
143
|
});
|
|
66
144
|
|
|
67
145
|
useEffect(() => {
|
|
@@ -69,5 +147,5 @@ export function useTimelineScrollViewport(
|
|
|
69
147
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
148
|
}, [syncShortcutHintVisibility, ...resyncShortcutHintOn]);
|
|
71
149
|
|
|
72
|
-
return {
|
|
150
|
+
return { viewport, showShortcutHint, setScrollRef, syncScrollViewport };
|
|
73
151
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
4
|
+
|
|
5
|
+
export function useTimelineSelectionLifecycle(
|
|
6
|
+
elements: TimelineElement[],
|
|
7
|
+
selectedElementId: string | null,
|
|
8
|
+
setShowPopover: (show: boolean) => void,
|
|
9
|
+
clearRangeSelection: () => void,
|
|
10
|
+
): void {
|
|
11
|
+
const selectedElement = useMemo(
|
|
12
|
+
() =>
|
|
13
|
+
elements.find((element) => getTimelineElementIdentity(element) === selectedElementId) ?? null,
|
|
14
|
+
[elements, selectedElementId],
|
|
15
|
+
);
|
|
16
|
+
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
|
|
17
|
+
selectedElementRef.current = selectedElement;
|
|
18
|
+
const previousSelectedRef = useRef(selectedElementRef.current);
|
|
19
|
+
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const previous = previousSelectedRef.current;
|
|
22
|
+
const current = selectedElementRef.current;
|
|
23
|
+
previousSelectedRef.current = current;
|
|
24
|
+
if (previous && !current) {
|
|
25
|
+
setShowPopover(false);
|
|
26
|
+
clearRangeSelection();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
+
|
|
4
|
+
export function useTimelineShiftModifier(): boolean {
|
|
5
|
+
const [shiftHeld, setShiftHeld] = useState(false);
|
|
6
|
+
useMountEffect(() => {
|
|
7
|
+
const handleKey = (event: KeyboardEvent) =>
|
|
8
|
+
event.key === "Shift" && setShiftHeld(event.type === "keydown");
|
|
9
|
+
const handleBlur = () => setShiftHeld(false);
|
|
10
|
+
window.addEventListener("keydown", handleKey);
|
|
11
|
+
window.addEventListener("keyup", handleKey);
|
|
12
|
+
window.addEventListener("blur", handleBlur);
|
|
13
|
+
return () => {
|
|
14
|
+
window.removeEventListener("keydown", handleKey);
|
|
15
|
+
window.removeEventListener("keyup", handleKey);
|
|
16
|
+
window.removeEventListener("blur", handleBlur);
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
return shiftHeld;
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
3
|
+
import type { TimelineTimeDisplayMode } from "../../utils/studioUiPreferences";
|
|
4
|
+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
5
|
+
import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
|
|
6
|
+
|
|
7
|
+
export function useTimelineTicks(
|
|
8
|
+
duration: number,
|
|
9
|
+
pixelsPerSecond: number,
|
|
10
|
+
timeDisplayMode: TimelineTimeDisplayMode,
|
|
11
|
+
renderTimeRange?: TimelineTimeRange,
|
|
12
|
+
) {
|
|
13
|
+
const frameRate = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
|
|
14
|
+
const ticks = useMemo(
|
|
15
|
+
() => generateTicks(duration, pixelsPerSecond, frameRate, renderTimeRange),
|
|
16
|
+
[duration, frameRate, pixelsPerSecond, renderTimeRange],
|
|
17
|
+
);
|
|
18
|
+
return {
|
|
19
|
+
...ticks,
|
|
20
|
+
majorTickInterval: getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -15,6 +15,28 @@ afterEach(() => {
|
|
|
15
15
|
usePlayerStore.getState().reset();
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
+
function renderTrackLayout(
|
|
19
|
+
elements: TimelineElement[],
|
|
20
|
+
animations: Map<string, GsapAnimation[]>,
|
|
21
|
+
): {
|
|
22
|
+
layout: ReturnType<typeof useTimelineTrackLayout>;
|
|
23
|
+
unmount: () => void;
|
|
24
|
+
} {
|
|
25
|
+
usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
|
|
26
|
+
|
|
27
|
+
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
|
28
|
+
function Probe() {
|
|
29
|
+
layout = useTimelineTrackLayout(elements, animations, null, new Set());
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const root = createRoot(document.createElement("div"));
|
|
34
|
+
act(() => root.render(React.createElement(Probe)));
|
|
35
|
+
if (!layout) throw new Error("Timeline track layout did not render");
|
|
36
|
+
|
|
37
|
+
return { layout, unmount: () => act(() => root.unmount()) };
|
|
38
|
+
}
|
|
39
|
+
|
|
18
40
|
describe("useTimelineTrackLayout", () => {
|
|
19
41
|
it("counts a flat tween lane and reserves its expanded row height", () => {
|
|
20
42
|
const elements: TimelineElement[] = [
|
|
@@ -36,20 +58,13 @@ describe("useTimelineTrackLayout", () => {
|
|
|
36
58
|
],
|
|
37
59
|
],
|
|
38
60
|
]);
|
|
39
|
-
|
|
61
|
+
const { layout, unmount } = renderTrackLayout(elements, animations);
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const root = createRoot(document.createElement("div"));
|
|
48
|
-
act(() => root.render(React.createElement(Probe)));
|
|
49
|
-
|
|
50
|
-
expect(layout?.laneCounts.get("clip-1")).toBe(1);
|
|
51
|
-
expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]);
|
|
52
|
-
act(() => root.unmount());
|
|
63
|
+
expect(layout.laneCounts.get("clip-1")).toBe(1);
|
|
64
|
+
expect(layout.rowHeights).toEqual([TRACK_H + LANE_H]);
|
|
65
|
+
expect(layout.rowGeometry.rowKeys).toEqual([0]);
|
|
66
|
+
expect(layout.rowGeometry.canvasHeight).toBeGreaterThan(TRACK_H + LANE_H);
|
|
67
|
+
unmount();
|
|
53
68
|
});
|
|
54
69
|
|
|
55
70
|
// The row height reserved here and the lanes actually rendered are two
|
|
@@ -69,20 +84,11 @@ describe("useTimelineTrackLayout", () => {
|
|
|
69
84
|
properties: { x: 420, opacity: 1 },
|
|
70
85
|
};
|
|
71
86
|
const animations = new Map<string, GsapAnimation[]>([["clip-1", [mixed]]]);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
|
75
|
-
function Probe() {
|
|
76
|
-
layout = useTimelineTrackLayout(elements, animations, null, new Set());
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const root = createRoot(document.createElement("div"));
|
|
81
|
-
act(() => root.render(React.createElement(Probe)));
|
|
87
|
+
const { layout, unmount } = renderTrackLayout(elements, animations);
|
|
82
88
|
|
|
83
89
|
expect(getTimelinePropertyLanes([mixed], 0, 1)).toHaveLength(2);
|
|
84
|
-
expect(layout
|
|
85
|
-
expect(layout
|
|
86
|
-
|
|
90
|
+
expect(layout.laneCounts.get("clip-1")).toBe(2);
|
|
91
|
+
expect(layout.rowHeights).toEqual([TRACK_H + 2 * LANE_H]);
|
|
92
|
+
unmount();
|
|
87
93
|
});
|
|
88
94
|
});
|
|
@@ -6,8 +6,8 @@ import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
|
6
6
|
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
|
7
7
|
import {
|
|
8
8
|
TRACK_H,
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
createTimelineRowGeometry,
|
|
10
|
+
type TimelineRowGeometry,
|
|
11
11
|
trackHeights,
|
|
12
12
|
type TimelineTrackHeightClip,
|
|
13
13
|
} from "./timelineLayout";
|
|
@@ -72,7 +72,7 @@ function useTimelineRowHeights(
|
|
|
72
72
|
selectedElementIds: ReadonlySet<string>,
|
|
73
73
|
) {
|
|
74
74
|
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
75
|
-
const { laneCounts,
|
|
75
|
+
const { laneCounts, rowGeometry } = useMemo(() => {
|
|
76
76
|
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
|
|
77
77
|
// Row height follows only the active keyframe clip, so a track with several
|
|
78
78
|
// keyframed elements never reserves empty lanes for the ones not shown.
|
|
@@ -87,14 +87,23 @@ function useTimelineRowHeights(
|
|
|
87
87
|
const clipId = active.key ?? active.id;
|
|
88
88
|
return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }];
|
|
89
89
|
});
|
|
90
|
+
const rowHeights = trackHeights(heightTracks, expandedClipIds);
|
|
90
91
|
return {
|
|
91
92
|
laneCounts,
|
|
92
|
-
|
|
93
|
+
rowGeometry: createTimelineRowGeometry(
|
|
94
|
+
tracks.map(([track]) => track),
|
|
95
|
+
rowHeights,
|
|
96
|
+
),
|
|
93
97
|
};
|
|
94
98
|
}, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
return {
|
|
99
|
+
const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
|
|
100
|
+
rowGeometryRef.current = rowGeometry;
|
|
101
|
+
return {
|
|
102
|
+
laneCounts,
|
|
103
|
+
rowGeometry,
|
|
104
|
+
rowGeometryRef,
|
|
105
|
+
rowHeights: rowGeometry.rowHeights,
|
|
106
|
+
};
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
export function useTimelineTrackLayout(
|
|
@@ -106,7 +115,7 @@ export function useTimelineTrackLayout(
|
|
|
106
115
|
const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
|
|
107
116
|
const trackOrderRef = useRef(trackOrder);
|
|
108
117
|
trackOrderRef.current = trackOrder;
|
|
109
|
-
const { laneCounts,
|
|
118
|
+
const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
|
|
110
119
|
tracks,
|
|
111
120
|
gsapAnimations,
|
|
112
121
|
selectedElementId,
|
|
@@ -119,23 +128,23 @@ export function useTimelineTrackLayout(
|
|
|
119
128
|
trackOrder,
|
|
120
129
|
trackOrderRef,
|
|
121
130
|
laneCounts,
|
|
131
|
+
rowGeometry,
|
|
132
|
+
rowGeometryRef,
|
|
122
133
|
rowHeights,
|
|
123
|
-
rowHeightsRef,
|
|
124
134
|
};
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
function useDisplayRowHeights(
|
|
128
138
|
displayTrackOrder: readonly number[],
|
|
129
|
-
|
|
130
|
-
rowHeights: readonly number[],
|
|
139
|
+
rowGeometry: TimelineRowGeometry,
|
|
131
140
|
) {
|
|
132
141
|
return useMemo(
|
|
133
142
|
() =>
|
|
134
143
|
displayTrackOrder.map((track) => {
|
|
135
|
-
const row =
|
|
136
|
-
return row < 0 ? TRACK_H :
|
|
144
|
+
const row = rowGeometry.getRowIndex(track);
|
|
145
|
+
return row < 0 ? TRACK_H : rowGeometry.getRowHeight(row);
|
|
137
146
|
}),
|
|
138
|
-
[displayTrackOrder,
|
|
147
|
+
[displayTrackOrder, rowGeometry],
|
|
139
148
|
);
|
|
140
149
|
}
|
|
141
150
|
|
|
@@ -149,10 +158,18 @@ function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder:
|
|
|
149
158
|
export function useTimelineDisplayLayout(
|
|
150
159
|
draggedClip: DraggedClipState | null,
|
|
151
160
|
trackOrder: number[],
|
|
152
|
-
|
|
161
|
+
rowGeometry: TimelineRowGeometry,
|
|
153
162
|
) {
|
|
154
163
|
const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder);
|
|
155
|
-
const displayRowHeights = useDisplayRowHeights(displayTrackOrder,
|
|
156
|
-
const
|
|
157
|
-
|
|
164
|
+
const displayRowHeights = useDisplayRowHeights(displayTrackOrder, rowGeometry);
|
|
165
|
+
const displayRowGeometry = useMemo(
|
|
166
|
+
() => createTimelineRowGeometry(displayTrackOrder, displayRowHeights),
|
|
167
|
+
[displayTrackOrder, displayRowHeights],
|
|
168
|
+
);
|
|
169
|
+
return {
|
|
170
|
+
displayTrackOrder,
|
|
171
|
+
displayRowHeights: displayRowGeometry.rowHeights,
|
|
172
|
+
rowGeometry: displayRowGeometry,
|
|
173
|
+
totalH: displayRowGeometry.canvasHeight,
|
|
174
|
+
};
|
|
158
175
|
}
|