Skip to content

Commit 6fec739

Browse files
committed
feat(studio): NLE shell assembly (unwired)
What: EditorShell (the full editor layout replacing NLELayout + StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and useTimelineEditCallbacks (the callback bag bridging store edits to the timeline), all unwired. Why: the shell that App swaps to in the final step; reviewing it standalone keeps that swap PR small. How: new files against the coexistence layer; TEMP(studio-dnd) entries until App mounts EditorShell in the app-shell swap. Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit clean.
1 parent dd1310c commit 6fec739

4 files changed

Lines changed: 688 additions & 0 deletions

File tree

.fallowrc.jsonc

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@
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/EditorShell.tsx",
59+
"packages/studio/src/components/nle/TimelinePane.tsx",
60+
"packages/studio/src/components/nle/useTimelineEditCallbacks.ts",
61+
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
62+
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
5863
"packages/studio/src/components/editor/DomEditSelectionChrome.tsx",
5964
"packages/studio/src/components/editor/useDomEditNudge.ts",
6065
"packages/studio/src/components/nle/PreviewOverlays.tsx",
@@ -451,6 +456,18 @@
451456
// require intrusive middleware changes beyond this PR's scope.
452457
"minLines": 6,
453458
"ignore": [
459+
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
460+
// studio-dnd pr22 removes this with the final config.
461+
"packages/studio/src/components/StudioPreviewArea.tsx",
462+
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
463+
// studio-dnd pr22 removes this with the final config.
464+
"packages/studio/src/components/nle/useTimelineEditCallbacks.ts",
465+
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
466+
// studio-dnd pr22 removes this with the final config.
467+
"packages/studio/src/components/EditorShell.tsx",
468+
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
469+
// studio-dnd pr22 removes this with the final config.
470+
"packages/studio/src/components/nle/PreviewOverlays.tsx",
454471
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
455472
// studio-dnd pr22 removes this with the final config.
456473
"packages/studio/src/components/editor/DomEditOverlay.tsx",
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { useCallback, type ReactNode } from "react";
2+
import { PreviewPane } from "./nle/PreviewPane";
3+
import { TimelinePane } from "./nle/TimelinePane";
4+
import { PreviewOverlays } from "./nle/PreviewOverlays";
5+
import {
6+
useTimelineEditCallbacks,
7+
type TimelineEditCallbackDeps,
8+
} from "./nle/useTimelineEditCallbacks";
9+
import { NLEProvider, useNLEContext } from "./nle/NLEContext";
10+
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
11+
import { StudioFeedbackBar } from "./StudioFeedbackBar";
12+
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
13+
import { useDomEditActionsContext } from "../contexts/DomEditContext";
14+
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
15+
import type { TimelineElement } from "../player";
16+
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
17+
import type { GestureRecordingState } from "./editor/GestureRecordControl";
18+
19+
type RenderClipContent = (
20+
element: TimelineElement,
21+
style: { clip: string; label: string },
22+
) => ReactNode;
23+
type TimelineDropPlacement = Pick<TimelineElement, "start" | "track">;
24+
25+
// The seven move/resize/split/razor handlers come from TimelineEditCallbackDeps
26+
// (shared with useTimelineEditCallbacks); the rest are drop + wiring props.
27+
export interface EditorShellProps extends TimelineEditCallbackDeps {
28+
/** Left sidebar (media/library), rendered in the top row. */
29+
left: ReactNode;
30+
/** Right panel (inspector/design) or null when collapsed, in the top row. */
31+
right: ReactNode;
32+
/** Hide the whole shell (e.g. while the storyboard view is active). */
33+
hidden?: boolean;
34+
timelineToolbar: ReactNode;
35+
renderClipContent: RenderClipContent;
36+
handleTimelineElementDelete: (element: TimelineElement) => Promise<void> | void;
37+
handleTimelineAssetDrop: (
38+
assetPath: string,
39+
placement: TimelineDropPlacement,
40+
) => Promise<void> | void;
41+
handleTimelineBlockDrop?: (
42+
blockName: string,
43+
placement: TimelineDropPlacement,
44+
) => Promise<void> | void;
45+
handlePreviewBlockDrop?: (
46+
blockName: string,
47+
position: { left: number; top: number },
48+
) => Promise<void> | void;
49+
handleTimelineFileDrop: (
50+
files: File[],
51+
placement?: TimelineDropPlacement,
52+
) => Promise<void> | void;
53+
setCompIdToSrc: (map: Map<string, string>) => void;
54+
setCompositionLoading: (loading: boolean) => void;
55+
shouldShowSelectedDomBounds: boolean;
56+
blockPreview?: BlockPreviewInfo | null;
57+
isGestureRecording?: boolean;
58+
recordingState?: GestureRecordingState;
59+
onToggleRecording?: () => void;
60+
gestureOverlay?: ReactNode;
61+
}
62+
63+
// The CapCut-style shell: [left | preview | right] in a top row, with a
64+
// full-width timeline spanning the bottom. Owns the shared player +
65+
// composition-stack state via NLEProvider so both rows share one player.
66+
export function EditorShell({
67+
left,
68+
right,
69+
hidden,
70+
timelineToolbar,
71+
renderClipContent,
72+
handleTimelineElementDelete,
73+
handleTimelineAssetDrop,
74+
handleTimelineBlockDrop,
75+
handlePreviewBlockDrop,
76+
handleTimelineFileDrop,
77+
handleTimelineElementMove,
78+
handleTimelineElementsMove,
79+
handleTimelineElementResize,
80+
handleToggleTrackHidden,
81+
handleBlockedTimelineEdit,
82+
handleTimelineElementSplit,
83+
handleRazorSplit,
84+
handleRazorSplitAll,
85+
setCompIdToSrc,
86+
setCompositionLoading,
87+
shouldShowSelectedDomBounds,
88+
isGestureRecording,
89+
recordingState,
90+
onToggleRecording,
91+
blockPreview,
92+
gestureOverlay,
93+
}: EditorShellProps) {
94+
const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef } =
95+
useStudioShellContext();
96+
const { refreshKey, captionEditMode, refreshPreviewDocumentVersion } = useStudioPlaybackContext();
97+
const { handleTimelineElementSelect } = useDomEditActionsContext();
98+
99+
const timelineEditCallbacks = useTimelineEditCallbacks({
100+
handleTimelineElementMove,
101+
handleTimelineElementsMove,
102+
handleTimelineElementResize,
103+
handleToggleTrackHidden,
104+
handleBlockedTimelineEdit,
105+
handleTimelineElementSplit,
106+
handleRazorSplit,
107+
handleRazorSplitAll,
108+
});
109+
110+
return (
111+
<div className={`flex flex-col flex-1 min-h-0${hidden ? " hidden" : ""}`}>
112+
<TimelineEditProvider value={timelineEditCallbacks}>
113+
<NLEProvider
114+
projectId={projectId}
115+
refreshKey={refreshKey}
116+
activeCompositionPath={activeCompPath}
117+
onIframeRef={handlePreviewIframeRef}
118+
onCompIdToSrcChange={setCompIdToSrc}
119+
onCompositionLoadingChange={setCompositionLoading}
120+
onCompositionChange={(compPath) => {
121+
// Sync activeCompPath when the user drills down via the timeline or
122+
// navigates back — keeps sidebar + thumbnails in sync. Guard no-ops to
123+
// avoid circular refresh cascades (activeCompPath → stack → onChange).
124+
if (compPath !== activeCompPath) {
125+
setActiveCompPath(compPath);
126+
refreshPreviewDocumentVersion();
127+
}
128+
}}
129+
>
130+
<EditorShellBody
131+
left={left}
132+
right={right}
133+
captionEditMode={captionEditMode}
134+
onSelectTimelineElement={handleTimelineElementSelect}
135+
onPreviewBlockDrop={handlePreviewBlockDrop}
136+
timelineToolbar={timelineToolbar}
137+
renderClipContent={renderClipContent}
138+
onFileDrop={handleTimelineFileDrop}
139+
onAssetDrop={handleTimelineAssetDrop}
140+
onBlockDrop={handleTimelineBlockDrop}
141+
onDeleteElement={handleTimelineElementDelete}
142+
previewOverlay={
143+
<PreviewOverlays
144+
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
145+
blockPreview={blockPreview}
146+
isGestureRecording={isGestureRecording}
147+
recordingState={recordingState}
148+
onToggleRecording={onToggleRecording}
149+
gestureOverlay={gestureOverlay}
150+
/>
151+
}
152+
/>
153+
</NLEProvider>
154+
</TimelineEditProvider>
155+
<StudioFeedbackBar />
156+
</div>
157+
);
158+
}
159+
160+
interface EditorShellBodyProps {
161+
left: ReactNode;
162+
right: ReactNode;
163+
captionEditMode: boolean;
164+
previewOverlay: ReactNode;
165+
onSelectTimelineElement: (element: TimelineElement | null) => void;
166+
onPreviewBlockDrop?: (
167+
blockName: string,
168+
position: { left: number; top: number },
169+
) => Promise<void> | void;
170+
timelineToolbar: ReactNode;
171+
renderClipContent: RenderClipContent;
172+
onFileDrop: (files: File[], placement?: TimelineDropPlacement) => Promise<void> | void;
173+
onAssetDrop: (assetPath: string, placement: TimelineDropPlacement) => Promise<void> | void;
174+
onBlockDrop?: (blockName: string, placement: TimelineDropPlacement) => Promise<void> | void;
175+
onDeleteElement: (element: TimelineElement) => Promise<void> | void;
176+
}
177+
178+
function EditorShellBody({
179+
left,
180+
right,
181+
captionEditMode,
182+
previewOverlay,
183+
onSelectTimelineElement,
184+
onPreviewBlockDrop,
185+
timelineToolbar,
186+
renderClipContent,
187+
onFileDrop,
188+
onAssetDrop,
189+
onBlockDrop,
190+
onDeleteElement,
191+
}: EditorShellBodyProps) {
192+
const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
193+
194+
// Keyboard: Escape to pop composition level
195+
const handleKeyDown = useCallback(
196+
(e: React.KeyboardEvent) => {
197+
if (e.key === "Escape" && compositionStack.length > 1) {
198+
updateCompositionStack((prev) => prev.slice(0, -1));
199+
}
200+
},
201+
[compositionStack.length, updateCompositionStack],
202+
);
203+
204+
return (
205+
<div
206+
ref={containerRef}
207+
// Shell canvas is a step LIGHTER than the near-black panel cards so the
208+
// gaps between panels read as visible seams (CapCut-style).
209+
className="flex flex-col flex-1 min-h-0 bg-[#18181B]"
210+
onKeyDown={handleKeyDown}
211+
tabIndex={-1}
212+
>
213+
{/* Top row: [left | preview | right] — outer padding + the 8px resize
214+
seams give the panels CapCut-style separation on the dark canvas. */}
215+
<div className="flex flex-row flex-1 min-h-0 px-px pt-px">
216+
{left}
217+
<div className="flex-1 min-w-0 flex flex-col relative">
218+
<PreviewPane
219+
previewOverlay={previewOverlay}
220+
onSelectTimelineElement={onSelectTimelineElement}
221+
onPreviewBlockDrop={onPreviewBlockDrop}
222+
/>
223+
</div>
224+
{right}
225+
</div>
226+
227+
{/* Full-width timeline row */}
228+
<TimelinePane
229+
timelineToolbar={timelineToolbar}
230+
renderClipContent={renderClipContent}
231+
onFileDrop={onFileDrop}
232+
onAssetDrop={onAssetDrop}
233+
onBlockDrop={onBlockDrop}
234+
onDeleteElement={onDeleteElement}
235+
onSelectTimelineElement={onSelectTimelineElement}
236+
timelineFooter={
237+
captionEditMode ? (
238+
<div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
239+
<div className="flex items-center gap-1.5 px-2 py-0.5">
240+
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
241+
Captions
242+
</span>
243+
</div>
244+
<CaptionTimeline pixelsPerSecond={100} />
245+
</div>
246+
) : undefined
247+
}
248+
/>
249+
</div>
250+
);
251+
}

0 commit comments

Comments
 (0)