Skip to content

Commit 27cf79b

Browse files
committed
perf(studio): stabilize virtualized timeline drops
1 parent 0c2b929 commit 27cf79b

4 files changed

Lines changed: 392 additions & 51 deletions

File tree

packages/studio/src/player/components/Timeline.tsx

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -245,19 +245,19 @@ export const Timeline = memo(function Timeline({
245245
sessionEpoch,
246246
});
247247

248-
const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
249-
useTimelineAssetDrop({
250-
scrollRef,
251-
ppsRef,
252-
durationRef,
253-
trackOrderRef,
254-
rowGeometryRef,
255-
contentOrigin,
256-
onFileDrop: pinnedOnFileDrop,
257-
onAssetDrop: pinnedOnAssetDrop,
258-
onBlockDrop: pinnedOnBlockDrop,
259-
onCompositionDrop: pinnedOnCompositionDrop,
260-
});
248+
const assetDrop = useTimelineAssetDrop({
249+
scrollRef,
250+
ppsRef,
251+
durationRef,
252+
trackOrderRef,
253+
rowGeometryRef,
254+
contentOrigin,
255+
onFileDrop: pinnedOnFileDrop,
256+
onAssetDrop: pinnedOnAssetDrop,
257+
onBlockDrop: pinnedOnBlockDrop,
258+
onCompositionDrop: pinnedOnCompositionDrop,
259+
sessionEpoch,
260+
});
261261
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
262262
const resizingElementIds =
263263
resizingClip?.groupPreview?.map((change) => change.key) ??
@@ -436,11 +436,11 @@ export const Timeline = memo(function Timeline({
436436
if (!timelineReady || expandedElements.length === 0) {
437437
return (
438438
<TimelineEmptyState
439-
isDragOver={isDragOver}
439+
isDragOver={assetDrop.isDragOver}
440440
onFileDrop={!!onFileDrop}
441-
onDragOver={handleAssetDragOver}
442-
onDragLeave={() => clearDropPreview()}
443-
onDrop={handleAssetDrop}
441+
onDragOver={assetDrop.handleAssetDragOver}
442+
onDragLeave={assetDrop.handleAssetDragLeave}
443+
onDrop={assetDrop.handleAssetDrop}
444444
/>
445445
);
446446
}
@@ -450,7 +450,7 @@ export const Timeline = memo(function Timeline({
450450
ref={setContainerRef}
451451
aria-label="Timeline"
452452
data-timeline-element-count={expandedElements.length}
453-
className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
453+
className={`relative border-t select-none h-full overflow-hidden ${assetDrop.isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
454454
onMouseMove={updateRazorGuide}
455455
onMouseLeave={clearRazorGuide}
456456
style={{
@@ -472,9 +472,9 @@ export const Timeline = memo(function Timeline({
472472
syncScrollViewport(e.currentTarget, true);
473473
}}
474474
{...rowWindow.timelineFocusProps}
475-
onDragOver={handleAssetDragOver}
476-
onDragLeave={() => clearDropPreview()}
477-
onDrop={handleAssetDrop}
475+
onDragOver={assetDrop.handleAssetDragOver}
476+
onDragLeave={assetDrop.handleAssetDragLeave}
477+
onDrop={assetDrop.handleAssetDrop}
478478
onPointerDown={(e) => {
479479
// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
480480
// their own clicks — scrubbing here would preventDefault and eat them.
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from "react";
4+
import { createRoot } from "react-dom/client";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
7+
import { usePlayerStore } from "../store/playerStore";
8+
import { createTimelineRowGeometry } from "./timelineLayout";
9+
import { useTimelineAssetDrop } from "./timelineDragDrop";
10+
import { configureTimelineTestViewport } from "./timelineTestViewport";
11+
12+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13+
14+
interface DropTransfer {
15+
types: string[];
16+
files: File[];
17+
dropEffect: DataTransfer["dropEffect"];
18+
getData: (type: string) => string;
19+
}
20+
21+
function dragEvent(transfer: DropTransfer, clientX: number, clientY: number): React.DragEvent {
22+
return {
23+
clientX,
24+
clientY,
25+
dataTransfer: transfer,
26+
preventDefault: vi.fn(),
27+
} as unknown as React.DragEvent;
28+
}
29+
30+
function assetTransfer(payload: string): DropTransfer {
31+
return {
32+
types: [TIMELINE_ASSET_MIME],
33+
files: [],
34+
dropEffect: "none",
35+
getData: (type) => (type === TIMELINE_ASSET_MIME ? payload : ""),
36+
};
37+
}
38+
39+
function renderHarness(
40+
onAssetDrop: ReturnType<typeof vi.fn>,
41+
sessionEpoch = 1,
42+
options: { onBlockDrop?: ReturnType<typeof vi.fn>; strict?: boolean } = {},
43+
) {
44+
const tracks = Array.from({ length: 100 }, (_, index) => index);
45+
const geometry = createTimelineRowGeometry(
46+
tracks,
47+
tracks.map(() => 48),
48+
);
49+
const scroll = document.createElement("div");
50+
configureTimelineTestViewport(scroll, geometry.canvasHeight);
51+
document.body.append(scroll);
52+
const root = createRoot(document.createElement("div"));
53+
let api: ReturnType<typeof useTimelineAssetDrop> | null = null;
54+
55+
function Probe({ epoch }: { epoch: number }) {
56+
api = useTimelineAssetDrop({
57+
scrollRef: { current: scroll },
58+
ppsRef: { current: 40 },
59+
durationRef: { current: 120 },
60+
trackOrderRef: { current: tracks },
61+
rowGeometryRef: { current: geometry },
62+
contentOrigin: 0,
63+
sessionEpoch: epoch,
64+
onAssetDrop,
65+
onBlockDrop: options.onBlockDrop,
66+
});
67+
return null;
68+
}
69+
70+
const renderProbe = (epoch: number) =>
71+
root.render(
72+
options.strict ? (
73+
<React.StrictMode>
74+
<Probe epoch={epoch} />
75+
</React.StrictMode>
76+
) : (
77+
<Probe epoch={epoch} />
78+
),
79+
);
80+
act(() => renderProbe(sessionEpoch));
81+
return {
82+
scroll,
83+
root,
84+
get api() {
85+
if (!api) throw new Error("drop harness did not render");
86+
return api;
87+
},
88+
rerender(epoch: number) {
89+
act(() => renderProbe(epoch));
90+
},
91+
};
92+
}
93+
94+
afterEach(() => {
95+
vi.restoreAllMocks();
96+
usePlayerStore.getState().reset();
97+
document.body.innerHTML = "";
98+
});
99+
100+
describe("useTimelineAssetDrop", () => {
101+
it("edge-autoscrolls the sole timeline viewport while a supported asset is held", () => {
102+
let frame: FrameRequestCallback | null = null;
103+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((callback) => {
104+
frame = callback;
105+
return 1;
106+
});
107+
vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => undefined);
108+
const view = renderHarness(vi.fn());
109+
110+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 790, 120)));
111+
expect(view.api.isDragOver).toBe(true);
112+
expect(frame).not.toBeNull();
113+
act(() => frame?.(0));
114+
expect(view.scroll.scrollLeft).toBeGreaterThan(0);
115+
expect(view.scroll.scrollTop).toBe(0);
116+
117+
act(() => view.api.clearDropPreview());
118+
expect(view.api.isDragOver).toBe(false);
119+
act(() => view.root.unmount());
120+
});
121+
122+
it("keeps the drop actor while moving between descendants", () => {
123+
const view = renderHarness(vi.fn());
124+
const parent = document.createElement("div");
125+
const child = document.createElement("div");
126+
parent.append(child);
127+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
128+
act(() =>
129+
view.api.handleAssetDragLeave({
130+
relatedTarget: child,
131+
currentTarget: parent,
132+
} as unknown as React.DragEvent),
133+
);
134+
expect(view.api.isDragOver).toBe(true);
135+
act(() => view.root.unmount());
136+
});
137+
138+
it("drops once on a model row outside the mounted window and appends below the last row", () => {
139+
const onAssetDrop = vi.fn();
140+
const view = renderHarness(onAssetDrop);
141+
usePlayerStore.getState().setCurrentTime(12.5);
142+
view.scroll.scrollTop = view.scroll.scrollHeight - view.scroll.clientHeight;
143+
const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
144+
145+
act(() => {
146+
view.api.handleAssetDragOver(dragEvent(transfer, 400, 239));
147+
view.api.handleAssetDrop(dragEvent(transfer, 400, 239));
148+
});
149+
150+
expect(onAssetDrop).toHaveBeenCalledTimes(1);
151+
expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 12.5, track: 100 });
152+
expect(view.api.isDragOver).toBe(false);
153+
act(() => view.root.unmount());
154+
});
155+
156+
it("ignores malformed payloads and clears the actor on project reset", () => {
157+
const onAssetDrop = vi.fn();
158+
const view = renderHarness(onAssetDrop, 1);
159+
const transfer = assetTransfer("not-json");
160+
161+
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, 100)));
162+
expect(view.api.isDragOver).toBe(true);
163+
view.rerender(2);
164+
expect(view.api.isDragOver).toBe(false);
165+
166+
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, 100)));
167+
expect(onAssetDrop).not.toHaveBeenCalled();
168+
act(() => view.root.unmount());
169+
});
170+
171+
it("falls through a malformed asset payload to a valid block payload", () => {
172+
const onAssetDrop = vi.fn();
173+
const onBlockDrop = vi.fn();
174+
const view = renderHarness(onAssetDrop, 1, { onBlockDrop });
175+
const transfer: DropTransfer = {
176+
types: [TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME],
177+
files: [],
178+
dropEffect: "none",
179+
getData: (type) =>
180+
type === TIMELINE_ASSET_MIME
181+
? "not-json"
182+
: type === TIMELINE_BLOCK_MIME
183+
? JSON.stringify({ name: "title-card" })
184+
: "",
185+
};
186+
187+
act(() => {
188+
view.api.handleAssetDragOver(dragEvent(transfer, 400, 100));
189+
view.api.handleAssetDrop(dragEvent(transfer, 400, 100));
190+
});
191+
192+
expect(onAssetDrop).not.toHaveBeenCalled();
193+
expect(onBlockDrop).toHaveBeenCalledExactlyOnceWith("title-card", { start: 0, track: 0 });
194+
act(() => view.root.unmount());
195+
});
196+
197+
it("clears an escaped drag after StrictMode effect replay", () => {
198+
const view = renderHarness(vi.fn(), 1, { strict: true });
199+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
200+
expect(view.api.isDragOver).toBe(true);
201+
202+
act(() => window.dispatchEvent(new Event("dragend")));
203+
expect(view.api.isDragOver).toBe(false);
204+
act(() => view.root.unmount());
205+
});
206+
});

0 commit comments

Comments
 (0)