Skip to content

Commit 0ab5336

Browse files
committed
feat(studio): NLE shell components — context provider, panes, overlays (unwired)
What: the NLE shell layer, unwired: NLEContext (provider extracted from NLELayout), PreviewPane, AssetPreviewOverlay + assetPreviewStore, TimelineOverlays (menus/modal/hint extracted from Timeline), timelineClipChildren, timelineClipDragTypes, useTimelinePlayerLoop (playback loop extracted from useTimelinePlayer). Why: the extracted replacements for NLELayout/StudioPreviewArea and the oversized Timeline/useTimelinePlayer internals, reviewable standalone. How: new files only, tsc-clean against main. They intentionally duplicate blocks of their still-alive originals (duplication is warn-level in fallow; the swap PRs delete the originals). Dead-file findings covered by TEMP(studio-dnd) entry registrations, removed at the app-shell swap. Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit clean.
1 parent f20cc98 commit 0ab5336

10 files changed

Lines changed: 1056 additions & 0 deletions

File tree

.fallowrc.jsonc

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@
5555
"packages/studio/src/hooks/gsapRuntimePreview.ts",
5656
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
5757
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
58+
"packages/studio/src/components/nle/NLEContext.tsx",
59+
"packages/studio/src/components/nle/PreviewPane.tsx",
60+
"packages/studio/src/components/nle/AssetPreviewOverlay.tsx",
61+
"packages/studio/src/player/components/TimelineOverlays.tsx",
62+
"packages/studio/src/player/components/timelineClipChildren.tsx",
63+
"packages/studio/src/player/components/timelineClipDragTypes.ts",
64+
"packages/studio/src/player/hooks/useTimelinePlayerLoop.ts",
65+
"packages/studio/src/utils/assetPreviewStore.ts",
66+
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
67+
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
5868
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
5969
],
6070
"ignorePatterns": [
@@ -446,6 +456,9 @@
446456
// complexity pre-dates the computed-timeline work. Exempted at file level
447457
// rather than refactored as scope creep.
448458
"ignore": [
459+
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
460+
// removed by studio-dnd/pr22 when the final config lands.
461+
"packages/studio/src/player/components/TimelineOverlays.tsx",
449462
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
450463
// removed by studio-dnd/pr22 when the final config lands.
451464
"packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts",
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* CapCut-style asset preview overlay rendered inside PreviewPane.
3+
*
4+
* Shown when the user clicks an asset card that has NOT yet been added to the
5+
* timeline. Displays the media (image / video / audio) without modifying the
6+
* composition — no undo entry, no file mutation.
7+
*
8+
* Dismiss: X button, Escape key, or click on the scrim.
9+
* Switching to another not-added asset replaces the current preview.
10+
*/
11+
import { useEffect, useCallback } from "react";
12+
import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
13+
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
14+
15+
function basename(path: string): string {
16+
return path.split("/").pop() ?? path;
17+
}
18+
19+
type AssetKind = "image" | "video" | "audio";
20+
21+
function resolveAssetKind(path: string): AssetKind {
22+
if (VIDEO_EXT.test(path)) return "video";
23+
if (IMAGE_EXT.test(path)) return "image";
24+
return "audio";
25+
}
26+
27+
/** The media element for a previewed asset, chosen by kind. */
28+
function AssetPreviewMedia({
29+
kind,
30+
serveUrl,
31+
name,
32+
}: {
33+
kind: AssetKind;
34+
serveUrl: string;
35+
name: string;
36+
}) {
37+
if (kind === "image") {
38+
return (
39+
<img
40+
src={serveUrl}
41+
alt={name}
42+
className="max-w-full max-h-[70vh] rounded-md object-contain shadow-2xl"
43+
/>
44+
);
45+
}
46+
if (kind === "video") {
47+
return (
48+
<video
49+
src={serveUrl}
50+
controls
51+
autoPlay
52+
muted
53+
playsInline
54+
className="max-w-full max-h-[70vh] rounded-md shadow-2xl"
55+
/>
56+
);
57+
}
58+
return (
59+
<div className="flex flex-col items-center gap-4 px-6 py-8 rounded-xl bg-neutral-900 border border-neutral-800">
60+
<svg
61+
width="40"
62+
height="40"
63+
viewBox="0 0 24 24"
64+
fill="none"
65+
stroke="currentColor"
66+
strokeWidth="1.5"
67+
className="text-neutral-500"
68+
>
69+
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
70+
<circle cx="6" cy="18" r="3" />
71+
<circle cx="18" cy="16" r="3" />
72+
</svg>
73+
<audio src={serveUrl} controls className="w-64" />
74+
</div>
75+
);
76+
}
77+
78+
export function AssetPreviewOverlay() {
79+
const previewAsset = useAssetPreviewStore((s) => s.previewAsset);
80+
const previewProjectId = useAssetPreviewStore((s) => s.previewProjectId);
81+
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
82+
83+
const handleKeyDown = useCallback(
84+
(e: KeyboardEvent) => {
85+
if (e.key === "Escape") clearPreviewAsset();
86+
},
87+
[clearPreviewAsset],
88+
);
89+
90+
useEffect(() => {
91+
if (!previewAsset) return;
92+
window.addEventListener("keydown", handleKeyDown);
93+
return () => window.removeEventListener("keydown", handleKeyDown);
94+
}, [previewAsset, handleKeyDown]);
95+
96+
if (!previewAsset || !previewProjectId) return null;
97+
98+
const encodedAsset = previewAsset.split("/").map(encodeURIComponent).join("/");
99+
const serveUrl = `/api/projects/${previewProjectId}/preview/${encodedAsset}`;
100+
const name = basename(previewAsset);
101+
102+
return (
103+
<div
104+
className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80"
105+
onClick={clearPreviewAsset}
106+
role="dialog"
107+
aria-label={`Preview: ${name}`}
108+
aria-modal="true"
109+
>
110+
{/* Close button */}
111+
<button
112+
className="absolute top-3 right-3 w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
113+
onClick={(e) => {
114+
e.stopPropagation();
115+
clearPreviewAsset();
116+
}}
117+
aria-label="Close preview"
118+
>
119+
<svg
120+
width="12"
121+
height="12"
122+
viewBox="0 0 24 24"
123+
stroke="currentColor"
124+
strokeWidth="2.5"
125+
fill="none"
126+
strokeLinecap="round"
127+
>
128+
<line x1="18" y1="6" x2="6" y2="18" />
129+
<line x1="6" y1="6" x2="18" y2="18" />
130+
</svg>
131+
</button>
132+
133+
{/* Media */}
134+
<div
135+
className="flex flex-col items-center gap-3 max-w-[90%] max-h-[85%]"
136+
onClick={(e) => e.stopPropagation()}
137+
>
138+
<AssetPreviewMedia kind={resolveAssetKind(previewAsset)} serveUrl={serveUrl} name={name} />
139+
140+
{/* Filename label */}
141+
<span className="text-[12px] text-neutral-400 truncate max-w-full px-2 text-center">
142+
{name}
143+
</span>
144+
</div>
145+
</div>
146+
);
147+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// @vitest-environment happy-dom
2+
import React, { act } from "react";
3+
import { createRoot, type Root } from "react-dom/client";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { shouldDisableTimelineWhileCompositionLoading, NLEProvider } from "./NLEContext";
6+
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
7+
import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
8+
9+
installReactActEnvironment();
10+
11+
// Render NLEProvider into a fresh detached host and settle the mount effect.
12+
async function mountNleProvider(props: {
13+
projectId: string;
14+
refreshKey?: number;
15+
}): Promise<{ host: HTMLDivElement; root: Root }> {
16+
const host = document.createElement("div");
17+
document.body.append(host);
18+
const root = createRoot(host);
19+
await renderNleProvider(root, props);
20+
return { host, root };
21+
}
22+
23+
// Re-render NLEProvider into an existing root and settle effects.
24+
async function renderNleProvider(
25+
root: Root,
26+
props: { projectId: string; refreshKey?: number },
27+
): Promise<void> {
28+
await act(async () => {
29+
root.render(React.createElement(NLEProvider, props, React.createElement("div")));
30+
await Promise.resolve();
31+
});
32+
}
33+
34+
describe("timeline loading disable state", () => {
35+
it("disables the timeline while the composition loading overlay is visible", () => {
36+
expect(shouldDisableTimelineWhileCompositionLoading(true)).toBe(true);
37+
});
38+
39+
it("reenables the timeline after composition loading finishes", () => {
40+
expect(shouldDisableTimelineWhileCompositionLoading(false)).toBe(false);
41+
});
42+
});
43+
44+
describe("NLEProvider — asset preview scoping", () => {
45+
beforeEach(() => {
46+
// No project API in this unit test — stub fetch so the compIdToSrc mount
47+
// effect's request rejects quietly instead of hitting the network.
48+
vi.stubGlobal(
49+
"fetch",
50+
vi.fn(() => Promise.reject(new Error("no network in tests"))),
51+
);
52+
});
53+
54+
afterEach(() => {
55+
vi.unstubAllGlobals();
56+
useAssetPreviewStore.getState().clearPreviewAsset();
57+
});
58+
59+
it("clears a stale cross-project asset preview when the active project changes", async () => {
60+
const { host, root } = await mountNleProvider({ projectId: "project-a" });
61+
62+
useAssetPreviewStore.getState().setPreviewAsset("assets/photo.png", "project-a");
63+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/photo.png");
64+
65+
await renderNleProvider(root, { projectId: "project-b" });
66+
67+
// The overlay stays mounted across the project switch (EditorShell isn't
68+
// keyed by projectId) — the store itself must be the thing that clears.
69+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
70+
expect(useAssetPreviewStore.getState().previewProjectId).toBeNull();
71+
72+
act(() => root.unmount());
73+
host.remove();
74+
});
75+
76+
it("does not clear the preview on a re-render that keeps the same projectId", async () => {
77+
const { host, root } = await mountNleProvider({ projectId: "project-a" });
78+
79+
useAssetPreviewStore.getState().setPreviewAsset("assets/photo.png", "project-a");
80+
81+
await renderNleProvider(root, { projectId: "project-a", refreshKey: 1 });
82+
83+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/photo.png");
84+
85+
act(() => root.unmount());
86+
host.remove();
87+
});
88+
});

0 commit comments

Comments
 (0)