@hyperframes/studio 0.7.85 → 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-CXVkvR6C.js → hyperframes-player-BFqB5AWJ.js} +1 -1
- package/dist/assets/{index-CVcz5MoC.js → index-B1Vf4vCl.js} +1 -1
- package/dist/assets/index-BXkzp_OU.css +1 -0
- package/dist/assets/{index-DloBOPGY.js → index-BblzZ6Av.js} +221 -221
- package/dist/assets/{index-BZFIup06.js → index-D_JU4EeH.js} +1 -1
- package/dist/index.d.ts +14 -6
- package/dist/index.html +2 -2
- package/dist/index.js +6266 -4923
- 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/useTimelinePerformanceTelemetry.test.ts +68 -2
- package/src/player/components/useTimelinePerformanceTelemetry.ts +1 -1
- 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,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
|
+
}
|
|
@@ -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
|
});
|