Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 1 addition & 18 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
// Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel.
"packages/studio/src/components/editor/KeyframeDiamond.tsx",
"packages/studio/src/components/editor/SpringEaseEditor.tsx",
// NLE notice — rendered conditionally via NLELayout when timeline is first shown.
// NLE notice — rendered conditionally via NLEContext/EditorShell when timeline is first shown.
"packages/studio/src/components/nle/TimelineEditorNotice.tsx",
// Zoom hook extracted for downstream razor-blade PRs (#1330, #1331).
"packages/studio/src/player/components/useTimelineZoom.ts",
Expand All @@ -53,9 +53,6 @@
"packages/studio/src/hooks/gsapTargetCache.ts",
// Preview helper consumed dynamically from the studio iframe bridge.
"packages/studio/src/hooks/gsapRuntimePreview.ts",
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
],
"ignorePatterns": [
"docs/**",
Expand Down Expand Up @@ -93,11 +90,6 @@
"packages/cli/src/cloud/_gen/**",
],
"ignoreExports": [
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
{
"file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts",
"exports": ["readEffectiveZIndex"],
},
// drawElementService is the bottom of the fast-capture Graphite stack
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
// a per-PR audit diffing against the merge base sees these exports as
Expand Down Expand Up @@ -441,13 +433,6 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
// removed by studio-dnd/pr22 when the final config lands.
"packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts",
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
"packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts",
"packages/studio/src/player/components/timelineCollision.test.ts",
"packages/studio/src/player/components/timelineStackingSync.test.ts",
// sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
// patch*InTag pre-date this PR; only the PatchOperation type gained two
// optional fields, but the line-shift fingerprint re-flags the inherited
Expand Down Expand Up @@ -587,14 +572,12 @@
"packages/studio/src/components/editor/Transform3DCube.tsx",
"packages/studio/src/components/LintModal.tsx",
"packages/studio/src/components/MediaPreview.tsx",
"packages/studio/src/components/nle/NLELayout.tsx",
"packages/studio/src/components/nle/NLEPreview.tsx",
"packages/studio/src/components/sidebar/AudioRow.tsx",
"packages/studio/src/components/sidebar/BlocksTab.tsx",
"packages/studio/src/components/sidebar/CompositionsTab.tsx",
"packages/studio/src/components/sidebar/LeftSidebar.tsx",
"packages/studio/src/components/storyboard/StoryboardLoaded.tsx",
"packages/studio/src/components/StudioPreviewArea.tsx",
"packages/studio/src/components/StudioRightPanel.tsx",
"packages/studio/src/components/StudioToast.tsx",
"packages/studio/src/components/ui/Tooltip.tsx",
Expand Down
251 changes: 251 additions & 0 deletions packages/studio/src/components/EditorShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { useCallback, type ReactNode } from "react";
import { PreviewPane } from "./nle/PreviewPane";
import { TimelinePane } from "./nle/TimelinePane";
import { PreviewOverlays } from "./nle/PreviewOverlays";
import {
useTimelineEditCallbacks,
type TimelineEditCallbackDeps,
} from "./nle/useTimelineEditCallbacks";
import { NLEProvider, useNLEContext } from "./nle/NLEContext";
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
import { StudioFeedbackBar } from "./StudioFeedbackBar";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { useDomEditActionsContext } from "../contexts/DomEditContext";
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
import type { TimelineElement } from "../player";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
import type { GestureRecordingState } from "./editor/GestureRecordControl";

type RenderClipContent = (
element: TimelineElement,
style: { clip: string; label: string },
) => ReactNode;
type TimelineDropPlacement = Pick<TimelineElement, "start" | "track">;

// The seven move/resize/split/razor handlers come from TimelineEditCallbackDeps
// (shared with useTimelineEditCallbacks); the rest are drop + wiring props.
export interface EditorShellProps extends TimelineEditCallbackDeps {
/** Left sidebar (media/library), rendered in the top row. */
left: ReactNode;
/** Right panel (inspector/design) or null when collapsed, in the top row. */
right: ReactNode;
/** Hide the whole shell (e.g. while the storyboard view is active). */
hidden?: boolean;
timelineToolbar: ReactNode;
renderClipContent: RenderClipContent;
handleTimelineElementDelete: (element: TimelineElement) => Promise<void> | void;
handleTimelineAssetDrop: (
assetPath: string,
placement: TimelineDropPlacement,
) => Promise<void> | void;
handleTimelineBlockDrop?: (
blockName: string,
placement: TimelineDropPlacement,
) => Promise<void> | void;
handlePreviewBlockDrop?: (
blockName: string,
position: { left: number; top: number },
) => Promise<void> | void;
handleTimelineFileDrop: (
files: File[],
placement?: TimelineDropPlacement,
) => Promise<void> | void;
setCompIdToSrc: (map: Map<string, string>) => void;
setCompositionLoading: (loading: boolean) => void;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
isGestureRecording?: boolean;
recordingState?: GestureRecordingState;
onToggleRecording?: () => void;
gestureOverlay?: ReactNode;
}

// The CapCut-style shell: [left | preview | right] in a top row, with a
// full-width timeline spanning the bottom. Owns the shared player +
// composition-stack state via NLEProvider so both rows share one player.
export function EditorShell({
left,
right,
hidden,
timelineToolbar,
renderClipContent,
handleTimelineElementDelete,
handleTimelineAssetDrop,
handleTimelineBlockDrop,
handlePreviewBlockDrop,
handleTimelineFileDrop,
handleTimelineElementMove,
handleTimelineElementsMove,
handleTimelineElementResize,
handleToggleTrackHidden,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
handleRazorSplit,
handleRazorSplitAll,
setCompIdToSrc,
setCompositionLoading,
shouldShowSelectedDomBounds,
isGestureRecording,
recordingState,
onToggleRecording,
blockPreview,
gestureOverlay,
}: EditorShellProps) {
const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef } =
useStudioShellContext();
const { refreshKey, captionEditMode, refreshPreviewDocumentVersion } = useStudioPlaybackContext();
const { handleTimelineElementSelect } = useDomEditActionsContext();

const timelineEditCallbacks = useTimelineEditCallbacks({
handleTimelineElementMove,
handleTimelineElementsMove,
handleTimelineElementResize,
handleToggleTrackHidden,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
handleRazorSplit,
handleRazorSplitAll,
});

return (
<div className={`flex flex-col flex-1 min-h-0${hidden ? " hidden" : ""}`}>
<TimelineEditProvider value={timelineEditCallbacks}>
<NLEProvider
projectId={projectId}
refreshKey={refreshKey}
activeCompositionPath={activeCompPath}
onIframeRef={handlePreviewIframeRef}
onCompIdToSrcChange={setCompIdToSrc}
onCompositionLoadingChange={setCompositionLoading}
onCompositionChange={(compPath) => {
// Sync activeCompPath when the user drills down via the timeline or
// navigates back — keeps sidebar + thumbnails in sync. Guard no-ops to
// avoid circular refresh cascades (activeCompPath → stack → onChange).
if (compPath !== activeCompPath) {
setActiveCompPath(compPath);
refreshPreviewDocumentVersion();
}
}}
>
<EditorShellBody
left={left}
right={right}
captionEditMode={captionEditMode}
onSelectTimelineElement={handleTimelineElementSelect}
onPreviewBlockDrop={handlePreviewBlockDrop}
timelineToolbar={timelineToolbar}
renderClipContent={renderClipContent}
onFileDrop={handleTimelineFileDrop}
onAssetDrop={handleTimelineAssetDrop}
onBlockDrop={handleTimelineBlockDrop}
onDeleteElement={handleTimelineElementDelete}
previewOverlay={
<PreviewOverlays
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
blockPreview={blockPreview}
isGestureRecording={isGestureRecording}
recordingState={recordingState}
onToggleRecording={onToggleRecording}
gestureOverlay={gestureOverlay}
/>
}
/>
</NLEProvider>
</TimelineEditProvider>
<StudioFeedbackBar />
</div>
);
}

interface EditorShellBodyProps {
left: ReactNode;
right: ReactNode;
captionEditMode: boolean;
previewOverlay: ReactNode;
onSelectTimelineElement: (element: TimelineElement | null) => void;
onPreviewBlockDrop?: (
blockName: string,
position: { left: number; top: number },
) => Promise<void> | void;
timelineToolbar: ReactNode;
renderClipContent: RenderClipContent;
onFileDrop: (files: File[], placement?: TimelineDropPlacement) => Promise<void> | void;
onAssetDrop: (assetPath: string, placement: TimelineDropPlacement) => Promise<void> | void;
onBlockDrop?: (blockName: string, placement: TimelineDropPlacement) => Promise<void> | void;
onDeleteElement: (element: TimelineElement) => Promise<void> | void;
}

function EditorShellBody({
left,
right,
captionEditMode,
previewOverlay,
onSelectTimelineElement,
onPreviewBlockDrop,
timelineToolbar,
renderClipContent,
onFileDrop,
onAssetDrop,
onBlockDrop,
onDeleteElement,
}: EditorShellBodyProps) {
const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();

// Keyboard: Escape to pop composition level
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape" && compositionStack.length > 1) {
updateCompositionStack((prev) => prev.slice(0, -1));
}
},
[compositionStack.length, updateCompositionStack],
);

return (
<div
ref={containerRef}
// Shell canvas is a step LIGHTER than the near-black panel cards so the
// gaps between panels read as visible seams (CapCut-style).
className="flex flex-col flex-1 min-h-0 bg-[#18181B]"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
{/* Top row: [left | preview | right] — outer padding + the 8px resize
seams give the panels CapCut-style separation on the dark canvas. */}
<div className="flex flex-row flex-1 min-h-0 px-px pt-px">
{left}
<div className="flex-1 min-w-0 flex flex-col relative">
<PreviewPane
previewOverlay={previewOverlay}
onSelectTimelineElement={onSelectTimelineElement}
onPreviewBlockDrop={onPreviewBlockDrop}
/>
</div>
{right}
</div>

{/* Full-width timeline row */}
<TimelinePane
timelineToolbar={timelineToolbar}
renderClipContent={renderClipContent}
onFileDrop={onFileDrop}
onAssetDrop={onAssetDrop}
onBlockDrop={onBlockDrop}
onDeleteElement={onDeleteElement}
onSelectTimelineElement={onSelectTimelineElement}
timelineFooter={
captionEditMode ? (
<div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
<div className="flex items-center gap-1.5 px-2 py-0.5">
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
Captions
</span>
</div>
<CaptionTimeline pixelsPerSecond={100} />
</div>
) : undefined
}
/>
</div>
);
}
4 changes: 1 addition & 3 deletions packages/studio/src/components/StudioGlobalDragOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ export function StudioGlobalDragOverlay() {
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="text-sm font-medium text-studio-accent">
Drop files to import into project
</span>
<span className="text-sm font-medium text-studio-accent">Drop to add at the playhead</span>
</div>
</div>
);
Expand Down
11 changes: 8 additions & 3 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,12 +446,14 @@ export function StudioRightPanel({

return (
<>
{/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
the absolutely-positioned inner hit area. */}
<div
role="separator"
aria-label="Resize inspector panel"
aria-orientation="vertical"
tabIndex={0}
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center outline-none focus-visible:bg-studio-accent/20"
className="group relative w-[3px] flex-shrink-0 cursor-col-resize outline-none focus-visible:bg-studio-accent/20"
style={{ touchAction: "none" }}
onPointerDown={(e) => handlePanelResizeStart("right", e)}
onPointerMove={handlePanelResizeMove}
Expand All @@ -465,10 +467,13 @@ export function StudioRightPanel({
setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
}}
>
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
{/* Expanded hit zone: 8px wide, centered on the 3px seam */}
<div className="absolute inset-y-0 -left-[2.5px] w-2" />
{/* Visible hairline */}
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
</div>
<div
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden border-l border-neutral-800 bg-neutral-900"
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900"
style={{ width: rightWidth }}
>
{captionEditMode ? (
Expand Down
15 changes: 15 additions & 0 deletions packages/studio/src/components/editor/DomEditOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEdi
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
import { useMountEffect } from "../../hooks/useMountEffect";
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
import type { ZOrderPatch } from "./canvasContextMenuZOrder";

// Re-exports for external consumers — preserving existing import paths.
export {
Expand Down Expand Up @@ -73,6 +74,7 @@ interface DomEditOverlayProps {
onBoxSizeCommit: (
selection: DomEditSelection,
next: { width: number; height: number },
offset?: { x: number; y: number },
) => Promise<void> | void;
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
Expand All @@ -81,6 +83,19 @@ interface DomEditOverlayProps {
recordingState?: GestureRecordingState;
onToggleRecording?: () => void;
onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
/**
* Delete the selected canvas element.
* Wire to handleDomEditElementDelete from useDomEditActionsContext —
* same handler the Delete/Backspace hotkey uses.
*/
onDeleteSelection?: (selection: DomEditSelection) => void;
/**
* Called with the resolved z-order patch list after an optimistic DOM update.
* The patch list is tie-aware and may include sibling elements (see
* canvasContextMenuZOrder). Wire to handleDomZIndexReorderCommit from
* useDomEditActionsContext. See CanvasContextMenu.tsx module comment.
*/
onApplyZIndex?: (selection: DomEditSelection, patches: ZOrderPatch[]) => void;
}

// fallow-ignore-next-line complexity
Expand Down
Loading
Loading