Skip to content

Commit 44546e8

Browse files
committed
perf(studio): stabilize virtualized timeline drops
1 parent 0ab3f05 commit 44546e8

3 files changed

Lines changed: 322 additions & 45 deletions

File tree

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

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -263,19 +263,19 @@ export const Timeline = memo(function Timeline({
263263
sessionEpoch,
264264
});
265265

266-
const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
267-
useTimelineAssetDrop({
268-
scrollRef,
269-
ppsRef,
270-
durationRef,
271-
trackOrderRef,
272-
rowGeometryRef,
273-
contentOrigin,
274-
onFileDrop: pinnedOnFileDrop,
275-
onAssetDrop: pinnedOnAssetDrop,
276-
onBlockDrop: pinnedOnBlockDrop,
277-
onCompositionDrop: pinnedOnCompositionDrop,
278-
});
266+
const assetDrop = useTimelineAssetDrop({
267+
scrollRef,
268+
ppsRef,
269+
durationRef,
270+
trackOrderRef,
271+
rowGeometryRef,
272+
contentOrigin,
273+
onFileDrop: pinnedOnFileDrop,
274+
onAssetDrop: pinnedOnAssetDrop,
275+
onBlockDrop: pinnedOnBlockDrop,
276+
onCompositionDrop: pinnedOnCompositionDrop,
277+
sessionEpoch,
278+
});
279279

280280
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
281281
const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
@@ -444,11 +444,11 @@ export const Timeline = memo(function Timeline({
444444
if (!timelineReady || expandedElements.length === 0) {
445445
return (
446446
<TimelineEmptyState
447-
isDragOver={isDragOver}
447+
isDragOver={assetDrop.isDragOver}
448448
onFileDrop={!!onFileDrop}
449-
onDragOver={handleAssetDragOver}
450-
onDragLeave={() => clearDropPreview()}
451-
onDrop={handleAssetDrop}
449+
onDragOver={assetDrop.handleAssetDragOver}
450+
onDragLeave={assetDrop.handleAssetDragLeave}
451+
onDrop={assetDrop.handleAssetDrop}
452452
/>
453453
);
454454
}
@@ -458,7 +458,7 @@ export const Timeline = memo(function Timeline({
458458
ref={setContainerRef}
459459
aria-label="Timeline"
460460
data-timeline-element-count={expandedElements.length}
461-
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"}`}
461+
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"}`}
462462
onMouseMove={updateRazorGuide}
463463
onMouseLeave={clearRazorGuide}
464464
style={{
@@ -477,9 +477,9 @@ export const Timeline = memo(function Timeline({
477477
lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
478478
syncScrollViewport(e.currentTarget, true);
479479
}}
480-
onDragOver={handleAssetDragOver}
481-
onDragLeave={() => clearDropPreview()}
482-
onDrop={handleAssetDrop}
480+
onDragOver={assetDrop.handleAssetDragOver}
481+
onDragLeave={assetDrop.handleAssetDragLeave}
482+
onDrop={assetDrop.handleAssetDrop}
483483
onPointerDown={(e) => {
484484
// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
485485
// their own clicks — scrubbing here would preventDefault and eat them.
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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 } from "../../utils/timelineAssetDrop";
7+
import { usePlayerStore } from "../store/playerStore";
8+
import { createTimelineRowGeometry } from "./timelineLayout";
9+
import { useTimelineAssetDrop } from "./timelineDragDrop";
10+
11+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12+
13+
interface DropTransfer {
14+
types: string[];
15+
files: File[];
16+
dropEffect: DataTransfer["dropEffect"];
17+
getData: (type: string) => string;
18+
}
19+
20+
function dragEvent(transfer: DropTransfer, clientX: number, clientY: number): React.DragEvent {
21+
return {
22+
clientX,
23+
clientY,
24+
dataTransfer: transfer,
25+
preventDefault: vi.fn(),
26+
} as unknown as React.DragEvent;
27+
}
28+
29+
function assetTransfer(payload: string): DropTransfer {
30+
return {
31+
types: [TIMELINE_ASSET_MIME],
32+
files: [],
33+
dropEffect: "none",
34+
getData: (type) => (type === TIMELINE_ASSET_MIME ? payload : ""),
35+
};
36+
}
37+
38+
function renderHarness(onAssetDrop: ReturnType<typeof vi.fn>, sessionEpoch = 1) {
39+
const tracks = Array.from({ length: 100 }, (_, index) => index);
40+
const geometry = createTimelineRowGeometry(
41+
tracks,
42+
tracks.map(() => 48),
43+
);
44+
const scroll = document.createElement("div");
45+
scroll.getBoundingClientRect = () =>
46+
({ left: 0, top: 0, right: 800, bottom: 240, width: 800, height: 240 }) as DOMRect;
47+
Object.defineProperties(scroll, {
48+
scrollLeft: { configurable: true, writable: true, value: 0 },
49+
scrollTop: { configurable: true, writable: true, value: 0 },
50+
scrollWidth: { configurable: true, value: 10_000 },
51+
scrollHeight: { configurable: true, value: geometry.canvasHeight },
52+
clientWidth: { configurable: true, value: 800 },
53+
clientHeight: { configurable: true, value: 240 },
54+
});
55+
document.body.append(scroll);
56+
const root = createRoot(document.createElement("div"));
57+
let api: ReturnType<typeof useTimelineAssetDrop> | null = null;
58+
59+
function Probe({ epoch }: { epoch: number }) {
60+
api = useTimelineAssetDrop({
61+
scrollRef: { current: scroll },
62+
ppsRef: { current: 40 },
63+
durationRef: { current: 120 },
64+
trackOrderRef: { current: tracks },
65+
rowGeometryRef: { current: geometry },
66+
contentOrigin: 0,
67+
sessionEpoch: epoch,
68+
onAssetDrop,
69+
});
70+
return null;
71+
}
72+
73+
act(() => root.render(<Probe epoch={sessionEpoch} />));
74+
return {
75+
scroll,
76+
root,
77+
get api() {
78+
if (!api) throw new Error("drop harness did not render");
79+
return api;
80+
},
81+
rerender(epoch: number) {
82+
act(() => root.render(<Probe epoch={epoch} />));
83+
},
84+
};
85+
}
86+
87+
afterEach(() => {
88+
vi.restoreAllMocks();
89+
usePlayerStore.getState().reset();
90+
document.body.innerHTML = "";
91+
});
92+
93+
describe("useTimelineAssetDrop", () => {
94+
it("edge-autoscrolls the sole timeline viewport while a supported asset is held", () => {
95+
let frame: FrameRequestCallback | null = null;
96+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((callback) => {
97+
frame = callback;
98+
return 1;
99+
});
100+
vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => undefined);
101+
const view = renderHarness(vi.fn());
102+
103+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 790, 120)));
104+
expect(view.api.isDragOver).toBe(true);
105+
expect(frame).not.toBeNull();
106+
act(() => frame?.(0));
107+
expect(view.scroll.scrollLeft).toBeGreaterThan(0);
108+
expect(view.scroll.scrollTop).toBe(0);
109+
110+
act(() => view.api.clearDropPreview());
111+
expect(view.api.isDragOver).toBe(false);
112+
act(() => view.root.unmount());
113+
});
114+
115+
it("keeps the drop actor while moving between descendants", () => {
116+
const view = renderHarness(vi.fn());
117+
const parent = document.createElement("div");
118+
const child = document.createElement("div");
119+
parent.append(child);
120+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
121+
act(() =>
122+
view.api.handleAssetDragLeave({
123+
relatedTarget: child,
124+
currentTarget: parent,
125+
} as unknown as React.DragEvent),
126+
);
127+
expect(view.api.isDragOver).toBe(true);
128+
act(() => view.root.unmount());
129+
});
130+
131+
it("drops once on a model row outside the mounted window and appends below the last row", () => {
132+
const onAssetDrop = vi.fn();
133+
const view = renderHarness(onAssetDrop);
134+
usePlayerStore.getState().setCurrentTime(12.5);
135+
view.scroll.scrollTop = view.scroll.scrollHeight - view.scroll.clientHeight;
136+
const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
137+
138+
act(() => {
139+
view.api.handleAssetDragOver(dragEvent(transfer, 400, 239));
140+
view.api.handleAssetDrop(dragEvent(transfer, 400, 239));
141+
});
142+
143+
expect(onAssetDrop).toHaveBeenCalledTimes(1);
144+
expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 12.5, track: 100 });
145+
expect(view.api.isDragOver).toBe(false);
146+
act(() => view.root.unmount());
147+
});
148+
149+
it("ignores malformed payloads and clears the actor on project reset", () => {
150+
const onAssetDrop = vi.fn();
151+
const view = renderHarness(onAssetDrop, 1);
152+
const transfer = assetTransfer("not-json");
153+
154+
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, 100)));
155+
expect(view.api.isDragOver).toBe(true);
156+
view.rerender(2);
157+
expect(view.api.isDragOver).toBe(false);
158+
159+
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, 100)));
160+
expect(onAssetDrop).not.toHaveBeenCalled();
161+
act(() => view.root.unmount());
162+
});
163+
});

0 commit comments

Comments
 (0)