@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
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
|
+
|
|
9
|
+
class MockResizeObserver {
|
|
10
|
+
constructor(private readonly callback: ResizeObserverCallback) {}
|
|
11
|
+
observe(target: Element) {
|
|
12
|
+
this.callback(
|
|
13
|
+
[
|
|
14
|
+
{
|
|
15
|
+
target,
|
|
16
|
+
borderBoxSize: [{ inlineSize: target.clientWidth, blockSize: target.clientHeight }],
|
|
17
|
+
} as unknown as ResizeObserverEntry,
|
|
18
|
+
],
|
|
19
|
+
this as unknown as ResizeObserver,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
unobserve() {}
|
|
23
|
+
disconnect() {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
27
|
+
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
|
|
28
|
+
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
|
29
|
+
let clientWidth = 900;
|
|
30
|
+
let clientHeight = 240;
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
|
|
34
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
35
|
+
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
|
|
36
|
+
configurable: true,
|
|
37
|
+
get: () => clientWidth,
|
|
38
|
+
});
|
|
39
|
+
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
|
|
40
|
+
configurable: true,
|
|
41
|
+
get: () => clientHeight,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
clientWidth = 900;
|
|
47
|
+
clientHeight = 240;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
vi.unstubAllEnvs();
|
|
52
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
53
|
+
if (originalClientWidth)
|
|
54
|
+
Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
|
|
55
|
+
if (originalClientHeight)
|
|
56
|
+
Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
|
|
57
|
+
document.body.innerHTML = "";
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The virtualized list only mounts rows/clips after its ResizeObserver and the
|
|
62
|
+
* follow-up layout effect have both flushed, which is more than one React tick.
|
|
63
|
+
* A fixed number of flushes is a coin flip once the rest of the suite is
|
|
64
|
+
* competing for workers, so wait for the DOM the assertions actually need.
|
|
65
|
+
*/
|
|
66
|
+
async function settleUntil(predicate: () => boolean, tries = 60): Promise<void> {
|
|
67
|
+
for (let attempt = 0; attempt < tries; attempt++) {
|
|
68
|
+
if (predicate()) return;
|
|
69
|
+
await act(async () => {
|
|
70
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function advanceFrame(): Promise<void> {
|
|
76
|
+
await act(async () => {
|
|
77
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function mountTimeline(element: React.ReactElement) {
|
|
82
|
+
const host = document.createElement("div");
|
|
83
|
+
document.body.append(host);
|
|
84
|
+
const root = createRoot(host);
|
|
85
|
+
await act(async () => root.render(element));
|
|
86
|
+
await act(async () => {});
|
|
87
|
+
return { host, root };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function dispatchScroll(scroller: HTMLElement): Promise<void> {
|
|
91
|
+
await act(async () => {
|
|
92
|
+
scroller.dispatchEvent(new Event("scroll"));
|
|
93
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clipsByTrack(count: number, duration = 1, includeLabels = false) {
|
|
98
|
+
return Array.from({ length: count }, (_, track) => ({
|
|
99
|
+
id: `clip-${track}`,
|
|
100
|
+
...(includeLabels ? { label: `Clip ${track}` } : {}),
|
|
101
|
+
tag: "div",
|
|
102
|
+
start: 0,
|
|
103
|
+
duration,
|
|
104
|
+
track,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function scrollTimelineHorizontally(
|
|
109
|
+
scroller: HTMLElement,
|
|
110
|
+
scrollLeft: number,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
scroller.scrollLeft = scrollLeft;
|
|
113
|
+
await dispatchScroll(scroller);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// These tests mount 500-10,000 timeline elements and settle the virtualizer,
|
|
117
|
+
// which lands right on the 5s default once the rest of the suite is competing
|
|
118
|
+
// for workers. The generous ceiling is a flake guard, not an expected runtime.
|
|
119
|
+
describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
|
120
|
+
it("keeps a zero-size first render bounded while the feature flag is enabled", async () => {
|
|
121
|
+
clientWidth = 0;
|
|
122
|
+
clientHeight = 0;
|
|
123
|
+
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
|
124
|
+
import("./Timeline"),
|
|
125
|
+
import("../store/playerStore"),
|
|
126
|
+
]);
|
|
127
|
+
usePlayerStore.setState({
|
|
128
|
+
duration: 60,
|
|
129
|
+
timelineReady: true,
|
|
130
|
+
elements: clipsByTrack(10_000),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 2 }));
|
|
134
|
+
|
|
135
|
+
const rows = host.querySelectorAll('[role="listitem"]');
|
|
136
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
137
|
+
expect(rows.length).toBeLessThanOrEqual(16);
|
|
138
|
+
|
|
139
|
+
act(() => root.unmount());
|
|
140
|
+
usePlayerStore.getState().reset();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("defers rich clip content while scrolling without replacing the clip shell", async () => {
|
|
144
|
+
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
|
145
|
+
import("./Timeline"),
|
|
146
|
+
import("../store/playerStore"),
|
|
147
|
+
]);
|
|
148
|
+
usePlayerStore.setState({
|
|
149
|
+
duration: 60,
|
|
150
|
+
timelineReady: true,
|
|
151
|
+
selectedElementId: "clip-0",
|
|
152
|
+
elements: [{ id: "clip-0", label: "Clip 0", tag: "div", start: 0, duration: 10, track: 0 }],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const { host, root } = await mountTimeline(
|
|
156
|
+
React.createElement(Timeline, {
|
|
157
|
+
renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
try {
|
|
161
|
+
await act(async () => {
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 110));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
166
|
+
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
|
|
167
|
+
expect(scroller).not.toBeNull();
|
|
168
|
+
expect(clip).not.toBeNull();
|
|
169
|
+
expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
|
|
170
|
+
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
|
|
171
|
+
|
|
172
|
+
if (scroller) await dispatchScroll(scroller);
|
|
173
|
+
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
|
174
|
+
expect(host.querySelector("[data-rich-content]")).toBeNull();
|
|
175
|
+
|
|
176
|
+
await act(async () => {
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, 110));
|
|
178
|
+
});
|
|
179
|
+
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
|
180
|
+
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
|
|
181
|
+
} finally {
|
|
182
|
+
act(() => root.unmount());
|
|
183
|
+
usePlayerStore.getState().reset();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("mounts a bounded list range over the full geometry height", async () => {
|
|
188
|
+
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight, TRACK_H }] =
|
|
189
|
+
await Promise.all([
|
|
190
|
+
import("./Timeline"),
|
|
191
|
+
import("../store/playerStore"),
|
|
192
|
+
import("./timelineLayout"),
|
|
193
|
+
]);
|
|
194
|
+
usePlayerStore.setState({
|
|
195
|
+
duration: 60,
|
|
196
|
+
timelineReady: true,
|
|
197
|
+
elements: clipsByTrack(1_000),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 3 }));
|
|
201
|
+
|
|
202
|
+
await settleUntil(
|
|
203
|
+
() =>
|
|
204
|
+
(host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) >
|
|
205
|
+
0,
|
|
206
|
+
);
|
|
207
|
+
const list = host.querySelector<HTMLElement>('[role="list"]');
|
|
208
|
+
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
|
|
209
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
210
|
+
expect(rows.length).toBeLessThanOrEqual(16);
|
|
211
|
+
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
|
|
212
|
+
expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
|
|
213
|
+
expect(list?.parentElement?.style.height).toBe(
|
|
214
|
+
`${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const firstRow = rows[0] as HTMLElement;
|
|
218
|
+
const focusedControl = firstRow.querySelector<HTMLButtonElement>("button");
|
|
219
|
+
expect(focusedControl).not.toBeNull();
|
|
220
|
+
act(() => focusedControl?.focus());
|
|
221
|
+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
222
|
+
expect(scroller).not.toBeNull();
|
|
223
|
+
if (scroller) {
|
|
224
|
+
scroller.scrollTop = 500 * 48;
|
|
225
|
+
await act(async () => {
|
|
226
|
+
scroller.dispatchEvent(new Event("scroll"));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
|
|
230
|
+
expect(document.activeElement).toBe(focusedControl);
|
|
231
|
+
|
|
232
|
+
act(() => root.unmount());
|
|
233
|
+
usePlayerStore.getState().reset();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("windows clips and ruler cells while retaining an off-window selected clip", async () => {
|
|
237
|
+
const [{ Timeline }, { usePlayerStore }, { TIMELINE_VIEWPORT_BUDGETS }] = await Promise.all([
|
|
238
|
+
import("./Timeline"),
|
|
239
|
+
import("../store/playerStore"),
|
|
240
|
+
import("../lib/timelineViewportBudgets"),
|
|
241
|
+
]);
|
|
242
|
+
usePlayerStore.setState({
|
|
243
|
+
duration: 1_000,
|
|
244
|
+
timelineReady: true,
|
|
245
|
+
zoomMode: "manual",
|
|
246
|
+
manualZoomPercent: 2_000,
|
|
247
|
+
selectedElementId: "clip-490",
|
|
248
|
+
selectedElementIds: new Set(["clip-490"]),
|
|
249
|
+
// Repeated fixture shape intentionally contrasts row and clip windowing scales.
|
|
250
|
+
// fallow-ignore-next-line code-duplication
|
|
251
|
+
elements: Array.from({ length: 500 }, (_, index) => ({
|
|
252
|
+
id: `clip-${index}`,
|
|
253
|
+
tag: "div",
|
|
254
|
+
start: index * 2,
|
|
255
|
+
duration: 1,
|
|
256
|
+
track: 0,
|
|
257
|
+
})),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const host = document.createElement("div");
|
|
261
|
+
document.body.append(host);
|
|
262
|
+
const root = createRoot(host);
|
|
263
|
+
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 4 })));
|
|
264
|
+
await act(async () => {});
|
|
265
|
+
|
|
266
|
+
await settleUntil(() => host.querySelectorAll("[data-clip]").length > 1);
|
|
267
|
+
const initialClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
|
|
268
|
+
const initialGridCells = host.querySelectorAll("[data-timeline-grid-cell]");
|
|
269
|
+
expect(initialClips.length).toBeGreaterThan(1);
|
|
270
|
+
expect(initialClips.length).toBeLessThanOrEqual(
|
|
271
|
+
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
|
|
272
|
+
);
|
|
273
|
+
expect(initialGridCells.length).toBeLessThan(100);
|
|
274
|
+
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
|
|
275
|
+
const initialWindowIds = initialClips.map((clip) => clip.dataset.elId);
|
|
276
|
+
|
|
277
|
+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
278
|
+
expect(scroller).not.toBeNull();
|
|
279
|
+
if (scroller) await scrollTimelineHorizontally(scroller, 8_000);
|
|
280
|
+
|
|
281
|
+
const scrolledClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
|
|
282
|
+
expect(scrolledClips.map((clip) => clip.dataset.elId)).not.toEqual(initialWindowIds);
|
|
283
|
+
expect(scrolledClips.length).toBeLessThanOrEqual(
|
|
284
|
+
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
|
|
285
|
+
);
|
|
286
|
+
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
|
|
287
|
+
expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
|
|
288
|
+
|
|
289
|
+
await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
|
|
290
|
+
await advanceFrame();
|
|
291
|
+
await act(async () => {});
|
|
292
|
+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
|
293
|
+
const focusedClip = host.querySelector('[data-el-id="clip-300"]');
|
|
294
|
+
expect(document.activeElement).toBe(focusedClip);
|
|
295
|
+
await advanceFrame();
|
|
296
|
+
expect(host.querySelector('[data-el-id="clip-300"]')).toBe(focusedClip);
|
|
297
|
+
expect(document.activeElement).toBe(focusedClip);
|
|
298
|
+
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
|
|
299
|
+
|
|
300
|
+
if (scroller) await scrollTimelineHorizontally(scroller, 0);
|
|
301
|
+
expect(host.querySelector('[data-el-id="clip-300"]')).toBe(focusedClip);
|
|
302
|
+
expect(document.activeElement).toBe(focusedClip);
|
|
303
|
+
|
|
304
|
+
act(() => root.unmount());
|
|
305
|
+
usePlayerStore.getState().reset();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The rollback build mounts every clip, so the scroll-time concessions
|
|
311
|
+
* windowing makes are pure cost there. This block pins that explicit fallback
|
|
312
|
+
* to doing no per-frame work while a gesture runs.
|
|
313
|
+
*/
|
|
314
|
+
describe("Timeline without row virtualization", { timeout: 30_000 }, () => {
|
|
315
|
+
async function renderUnvirtualizedTimeline() {
|
|
316
|
+
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "0");
|
|
317
|
+
vi.resetModules();
|
|
318
|
+
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
|
319
|
+
import("./Timeline"),
|
|
320
|
+
import("../store/playerStore"),
|
|
321
|
+
]);
|
|
322
|
+
usePlayerStore.setState({
|
|
323
|
+
duration: 60,
|
|
324
|
+
timelineReady: true,
|
|
325
|
+
selectedElementId: "clip-0",
|
|
326
|
+
elements: clipsByTrack(40, 10, true),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const { host, root } = await mountTimeline(
|
|
330
|
+
React.createElement(Timeline, {
|
|
331
|
+
renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
return {
|
|
335
|
+
host,
|
|
336
|
+
dispose: () => {
|
|
337
|
+
act(() => root.unmount());
|
|
338
|
+
usePlayerStore.getState().reset();
|
|
339
|
+
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
|
|
340
|
+
vi.resetModules();
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
it("mounts every clip rather than a window", async () => {
|
|
346
|
+
const { host, dispose } = await renderUnvirtualizedTimeline();
|
|
347
|
+
try {
|
|
348
|
+
expect(host.querySelectorAll("[data-clip]").length).toBe(40);
|
|
349
|
+
} finally {
|
|
350
|
+
dispose();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("keeps clip content mounted across a scroll gesture", async () => {
|
|
355
|
+
const { host, dispose } = await renderUnvirtualizedTimeline();
|
|
356
|
+
try {
|
|
357
|
+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
358
|
+
expect(scroller).not.toBeNull();
|
|
359
|
+
const richBefore = host.querySelectorAll("[data-rich-content]").length;
|
|
360
|
+
expect(richBefore).toBe(40);
|
|
361
|
+
|
|
362
|
+
if (scroller) await dispatchScroll(scroller);
|
|
363
|
+
|
|
364
|
+
expect(host.querySelectorAll("[data-rich-content]").length).toBe(richBefore);
|
|
365
|
+
} finally {
|
|
366
|
+
dispose();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("does not swap clip content back in after the gesture settles", async () => {
|
|
371
|
+
const { host, dispose } = await renderUnvirtualizedTimeline();
|
|
372
|
+
try {
|
|
373
|
+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
374
|
+
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
|
|
375
|
+
if (scroller) await dispatchScroll(scroller);
|
|
376
|
+
await act(async () => {
|
|
377
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
|
381
|
+
expect(host.querySelectorAll("[data-rich-content]").length).toBe(40);
|
|
382
|
+
} finally {
|
|
383
|
+
dispose();
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("leaves the scroll position alone, so no snapshot round trip happens", async () => {
|
|
388
|
+
const { host, dispose } = await renderUnvirtualizedTimeline();
|
|
389
|
+
try {
|
|
390
|
+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
391
|
+
if (!scroller) throw new Error("Expected a timeline scroll viewport");
|
|
392
|
+
scroller.scrollTop = 400;
|
|
393
|
+
await dispatchScroll(scroller);
|
|
394
|
+
|
|
395
|
+
expect(scroller.scrollTop).toBe(400);
|
|
396
|
+
} finally {
|
|
397
|
+
dispose();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
});
|
|
@@ -23,8 +23,9 @@ import { TimelineClip } from "./TimelineClip";
|
|
|
23
23
|
import { TimelineLanes } from "./TimelineLanes";
|
|
24
24
|
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
25
25
|
import { renderClipChildren } from "./timelineClipChildren";
|
|
26
|
-
import { useTimelineRevealClip } from "./useTimelineRevealClip";
|
|
27
26
|
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
|
|
27
|
+
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
|
28
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
28
29
|
|
|
29
30
|
interface TimelineCanvasProps extends TimelineLaneBaseProps {
|
|
30
31
|
major: number[];
|
|
@@ -62,14 +63,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|
|
62
63
|
onRazorSplitAll,
|
|
63
64
|
} = useTimelineEditContextOptional();
|
|
64
65
|
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
|
65
|
-
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
|
|
66
|
-
useTimelineRevealClip(scrollRef);
|
|
67
66
|
const draggedElement = draggedClip?.element ?? null;
|
|
67
|
+
const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null;
|
|
68
68
|
const activeDraggedElement =
|
|
69
|
-
draggedClip?.started === true && draggedElement
|
|
69
|
+
draggedClip?.started === true && draggedElement && draggedElementIdentity
|
|
70
70
|
? getRenderedTimelineElement({
|
|
71
71
|
element: draggedElement,
|
|
72
|
-
draggedElementId:
|
|
72
|
+
draggedElementId: draggedElementIdentity,
|
|
73
73
|
previewStart: draggedClip.previewStart,
|
|
74
74
|
previewTrack: draggedClip.previewTrack,
|
|
75
75
|
})
|
|
@@ -85,10 +85,10 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|
|
85
85
|
// the wall together and never deforms. Matches what the commit will do — see
|
|
86
86
|
// timelineMultiDragPreview + commit.
|
|
87
87
|
const multiDragPreview: MultiDragPreviewInput | null =
|
|
88
|
-
draggedClip?.started === true && draggedElement
|
|
88
|
+
draggedClip?.started === true && draggedElement && draggedElementIdentity
|
|
89
89
|
? {
|
|
90
90
|
dragStarted: true,
|
|
91
|
-
draggedKey:
|
|
91
|
+
draggedKey: draggedElementIdentity,
|
|
92
92
|
draggedOriginStart: draggedElement.start,
|
|
93
93
|
draggedPreviewStart: draggedClip.previewStart,
|
|
94
94
|
selectedKeys: selectedElementIds,
|
|
@@ -126,11 +126,12 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|
|
126
126
|
theme={props.theme}
|
|
127
127
|
beatAnalysis={props.beatAnalysis}
|
|
128
128
|
contentOrigin={props.contentOrigin}
|
|
129
|
+
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
|
|
129
130
|
/>
|
|
130
131
|
|
|
131
132
|
{/* Breathing room between the sticky ruler and the first track lane — the
|
|
132
133
|
top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
|
|
133
|
-
<div aria-hidden="true" style={{ height: TRACKS_TOP_PAD }} />
|
|
134
|
+
<div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_TOP_PAD }} />
|
|
134
135
|
|
|
135
136
|
<TimelineLanes
|
|
136
137
|
{...props}
|
|
@@ -147,7 +148,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|
|
147
148
|
{/* Breathing room below the last track lane (~1.5 track heights) — a real
|
|
148
149
|
scrollable surface, so a clip can be dragged into the void to create a
|
|
149
150
|
new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
|
|
150
|
-
<div aria-hidden="true" style={{ height: TRACKS_BOTTOM_PAD }} />
|
|
151
|
+
<div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_BOTTOM_PAD }} />
|
|
151
152
|
|
|
152
153
|
{/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
|
|
153
154
|
menu row would collapse; a quiet tint for every gap on the selected
|
|
@@ -157,7 +158,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|
|
157
158
|
const rowIndex = displayTrackOrder.indexOf(strip.track);
|
|
158
159
|
if (rowIndex < 0) return null;
|
|
159
160
|
const loud = strip.kind === "hover";
|
|
160
|
-
|
|
161
|
+
const visibleIntervals = props.rowsVirtualized
|
|
162
|
+
? strip.intervals.filter(
|
|
163
|
+
(gap) =>
|
|
164
|
+
gap.start < props.renderTimeRange.end && gap.end > props.renderTimeRange.start,
|
|
165
|
+
)
|
|
166
|
+
: strip.intervals;
|
|
167
|
+
return visibleIntervals.map((gap) => (
|
|
161
168
|
<div
|
|
162
169
|
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
|
|
163
170
|
className="pointer-events-none absolute"
|
|
@@ -249,6 +256,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|
|
249
256
|
}
|
|
250
257
|
isHovered={false}
|
|
251
258
|
isDragging={true}
|
|
259
|
+
isActive={isTimelineClipActive(activeDraggedElement, props.currentTime)}
|
|
252
260
|
hasCustomContent={!!props.renderClipContent}
|
|
253
261
|
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
|
|
254
262
|
theme={props.theme}
|
|
@@ -12,6 +12,7 @@ interface TimelineClipProps {
|
|
|
12
12
|
isSelected: boolean;
|
|
13
13
|
isHovered: boolean;
|
|
14
14
|
isDragging?: boolean;
|
|
15
|
+
isActive?: boolean;
|
|
15
16
|
hasCustomContent: boolean;
|
|
16
17
|
capabilities: TimelineEditCapabilities;
|
|
17
18
|
theme?: TimelineTheme;
|
|
@@ -35,6 +36,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
35
36
|
isSelected,
|
|
36
37
|
isHovered,
|
|
37
38
|
isDragging = false,
|
|
39
|
+
isActive = false,
|
|
38
40
|
hasCustomContent,
|
|
39
41
|
capabilities,
|
|
40
42
|
theme = defaultTimelineTheme,
|
|
@@ -88,6 +90,8 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
88
90
|
data-clip-start={el.start}
|
|
89
91
|
data-clip-end={el.start + el.duration}
|
|
90
92
|
data-clip-hidden={el.hidden ? "true" : undefined}
|
|
93
|
+
data-active={isActive ? "" : undefined}
|
|
94
|
+
tabIndex={-1}
|
|
91
95
|
className={clipClassName}
|
|
92
96
|
style={style}
|
|
93
97
|
title={
|
|
@@ -33,6 +33,7 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
|
|
|
33
33
|
}}
|
|
34
34
|
clipWidthPx={200}
|
|
35
35
|
clipHeightPx={48}
|
|
36
|
+
clipDuration={10}
|
|
36
37
|
accentColor="#4ba3d2"
|
|
37
38
|
isSelected
|
|
38
39
|
currentPercentage={0}
|
|
@@ -46,10 +47,85 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
describe("TimelineClipDiamonds", () => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
it("marks only the nearest keyframe in a dense lane as under the playhead", () => {
|
|
51
|
+
const host = document.createElement("div");
|
|
52
|
+
document.body.append(host);
|
|
53
|
+
const root = createRoot(host);
|
|
54
|
+
act(() => {
|
|
55
|
+
root.render(
|
|
56
|
+
<TimelineDiamondLane
|
|
57
|
+
keyframesData={{
|
|
58
|
+
format: "percentage",
|
|
59
|
+
keyframes: [34.6, 34.8, 35, 35.2, 35.4].map((percentage) => ({
|
|
60
|
+
percentage,
|
|
61
|
+
propertyGroup: "position",
|
|
62
|
+
properties: { x: percentage },
|
|
63
|
+
})),
|
|
64
|
+
}}
|
|
65
|
+
clipWidthPx={4000}
|
|
66
|
+
clipHeightPx={48}
|
|
67
|
+
clipDuration={12}
|
|
68
|
+
accentColor="#4ba3d2"
|
|
69
|
+
isSelected
|
|
70
|
+
currentPercentage={35.05}
|
|
71
|
+
elementId="clip-1"
|
|
72
|
+
selectedKeyframes={new Set()}
|
|
73
|
+
groupAware
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(host.querySelectorAll('[data-keyframe-at-playhead="true"]')).toHaveLength(1);
|
|
79
|
+
expect(host.querySelector<HTMLButtonElement>('[data-keyframe-at-playhead="true"]')?.title).toBe(
|
|
80
|
+
"35%",
|
|
81
|
+
);
|
|
82
|
+
act(() => root.unmount());
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("distinguishes a playhead match from an explicitly selected keyframe", () => {
|
|
86
|
+
const selectedKey = timelineKeyframeSelectionKey("clip-1", {
|
|
87
|
+
percentage: 60,
|
|
88
|
+
propertyGroup: "position",
|
|
89
|
+
});
|
|
90
|
+
const host = document.createElement("div");
|
|
91
|
+
document.body.append(host);
|
|
92
|
+
const root = createRoot(host);
|
|
93
|
+
act(() => {
|
|
94
|
+
root.render(
|
|
95
|
+
<TimelineDiamondLane
|
|
96
|
+
keyframesData={{
|
|
97
|
+
format: "percentage",
|
|
98
|
+
keyframes: [
|
|
99
|
+
{ percentage: 40, propertyGroup: "position", properties: { x: 40 } },
|
|
100
|
+
{ percentage: 60, propertyGroup: "position", properties: { x: 60 } },
|
|
101
|
+
],
|
|
102
|
+
}}
|
|
103
|
+
clipWidthPx={1200}
|
|
104
|
+
clipHeightPx={48}
|
|
105
|
+
clipDuration={10}
|
|
106
|
+
accentColor="#4ba3d2"
|
|
107
|
+
isSelected
|
|
108
|
+
currentPercentage={40}
|
|
109
|
+
elementId="clip-1"
|
|
110
|
+
selectedKeyframes={new Set([selectedKey])}
|
|
111
|
+
groupAware
|
|
112
|
+
/>,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const playheadDiamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
|
|
117
|
+
const selectedDiamond = host.querySelector<HTMLButtonElement>('button[title="60%"]');
|
|
118
|
+
expect(playheadDiamond?.dataset.keyframeAtPlayhead).toBe("true");
|
|
119
|
+
expect(playheadDiamond?.dataset.keyframeSelected).toBe("false");
|
|
120
|
+
expect(playheadDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#a3a3a3");
|
|
121
|
+
expect(playheadDiamond?.querySelector('path[stroke="#4ba3d2"]')).not.toBeNull();
|
|
122
|
+
expect(selectedDiamond?.dataset.keyframeAtPlayhead).toBe("false");
|
|
123
|
+
expect(selectedDiamond?.dataset.keyframeSelected).toBe("true");
|
|
124
|
+
expect(selectedDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2");
|
|
125
|
+
act(() => root.unmount());
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("keeps dense keyframe visuals full-size while bounding their hit regions", () => {
|
|
53
129
|
const host = document.createElement("div");
|
|
54
130
|
document.body.append(host);
|
|
55
131
|
const root = createRoot(host);
|
|
@@ -80,7 +156,7 @@ describe("TimelineClipDiamonds", () => {
|
|
|
80
156
|
expect(diamonds).toHaveLength(3);
|
|
81
157
|
for (const diamond of diamonds) {
|
|
82
158
|
expect(Number.parseFloat(diamond.style.width)).toBeCloseTo(12);
|
|
83
|
-
expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).
|
|
159
|
+
expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBe(22);
|
|
84
160
|
}
|
|
85
161
|
act(() => root.unmount());
|
|
86
162
|
});
|
|
@@ -123,6 +199,7 @@ describe("TimelineClipDiamonds", () => {
|
|
|
123
199
|
keyframesData={{ format: "percentage", keyframes: [groupedKeyframe] }}
|
|
124
200
|
clipWidthPx={200}
|
|
125
201
|
clipHeightPx={48}
|
|
202
|
+
clipDuration={10}
|
|
126
203
|
accentColor="#4ba3d2"
|
|
127
204
|
isSelected
|
|
128
205
|
currentPercentage={-10}
|
|
@@ -271,6 +348,7 @@ describe("TimelineClipDiamonds", () => {
|
|
|
271
348
|
}}
|
|
272
349
|
clipWidthPx={200}
|
|
273
350
|
clipHeightPx={48}
|
|
351
|
+
clipDuration={10}
|
|
274
352
|
accentColor="#4ba3d2"
|
|
275
353
|
isSelected
|
|
276
354
|
currentPercentage={0}
|
|
@@ -356,6 +434,7 @@ describe("TimelineClipDiamonds", () => {
|
|
|
356
434
|
}}
|
|
357
435
|
clipWidthPx={200}
|
|
358
436
|
clipHeightPx={48}
|
|
437
|
+
clipDuration={10}
|
|
359
438
|
accentColor="#4ba3d2"
|
|
360
439
|
isSelected
|
|
361
440
|
currentPercentage={0}
|
|
@@ -620,6 +699,7 @@ describe("TimelineClipDiamonds", () => {
|
|
|
620
699
|
}}
|
|
621
700
|
clipWidthPx={200}
|
|
622
701
|
clipHeightPx={48}
|
|
702
|
+
clipDuration={10}
|
|
623
703
|
accentColor="#4ba3d2"
|
|
624
704
|
isSelected
|
|
625
705
|
currentPercentage={0}
|