Files
hyperframes/packages/studio/src/components/StudioRightPanel.tsx
T
Vance Ingalls 67696cd8de fix(studio): hide the Slideshow tab and panel for non-slideshow compositions
The Slideshow tab rendered unconditionally, showing the branching editor
for any composition regardless of whether it was actually a slideshow —
a plain video comp offered a tab with nothing meaningful to edit.

Gate it on the composition carrying the slideshow JSON island
(<script type="application/hyperframes-slideshow+json">), the same
definitive marker the CLI's `present` command already requires (it
refuses to run without one). Presence-only, not full manifest
validation, so a malformed island still surfaces the tab rather than
disappearing entirely. Also bounce rightPanelTab off "slideshow" to
"renders" if the active composition stops being a slideshow while that
tab is open (e.g. switching files), since its button would otherwise
vanish with no way back to it.

Extracted the gating + scene-list derivation into useSlideshowTabState
to keep StudioRightPanel.tsx under the 600-LOC gate.
2026-07-17 00:37:39 -07:00

593 lines
23 KiB
TypeScript

import { useCallback, useEffect, useRef, type MutableRefObject } from "react";
import { PropertyPanel } from "./editor/PropertyPanel";
import { LayersPanel } from "./editor/LayersPanel";
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
import { BlockParamsPanel } from "./editor/BlockParamsPanel";
import { RenderQueue } from "./renders/RenderQueue";
import { SlideshowPanel } from "./panels/SlideshowPanel";
import { VariablesPanel } from "./panels/VariablesPanel";
import { PanelTabButton } from "./PanelTabButton";
import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
import type { RenderJob } from "./renders/useRenderQueue";
import type { BlockParam } from "@hyperframes/core/registry";
import {
STUDIO_FLAT_INSPECTOR_ENABLED,
STUDIO_INSPECTOR_PANELS_ENABLED,
} from "./editor/manualEditingAvailability";
import type { Composition } from "@hyperframes/sdk";
import type { EditHistoryKind } from "../utils/editHistory";
import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useFileManagerContext } from "../contexts/FileManagerContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import { usePlayerStore } from "../player";
import { waitForMediaJob } from "./studioMediaJobs";
import {
applyColorGradingScopeUpdate,
EMPTY_COLOR_GRADING_SCOPE_RESULT,
type ColorGradingScope,
} from "./studioColorGradingScope";
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers";
import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
export interface StudioRightPanelProps {
designPanelActive: boolean;
activeBlockParams?: {
blockName: string;
blockTitle: string;
params: BlockParam[];
compositionPath: string;
} | null;
onCloseBlockParams?: () => void;
recordingState?: "idle" | "recording" | "preview";
recordingDuration?: number;
onToggleRecording?: () => void;
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
sdkSession: Composition | null;
publishSdkSession: NonNullable<UseSlideshowPersistParams["publishSdkSession"]>;
/**
* Forces THIS `sdkSession` to re-open from disk. DesignPanelPromoteProvider
* opens its own separate SDK session scoped to the selected element's own
* file (needed so promoting inside a sub-composition binds a variable there,
* not on the host) — for a top-level selection that's the SAME file this
* session already has open, so a write through that other session leaves
* this one holding stale in-memory content. The self-write-echo registry
* that normally suppresses redundant reloads is keyed by file path only, not
* by session instance, so it wrongly treats the sibling session's write as
* "our own echo" and never reloads on its own — this must be called
* explicitly after such a write.
*/
forceReloadSdkSession?: () => void;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
onToggleElementHidden?: ToggleHiddenHandler;
}
// fallow-ignore-next-line complexity
export function StudioRightPanel({
designPanelActive,
activeBlockParams,
onCloseBlockParams,
recordingState,
recordingDuration,
onToggleRecording,
sdkSession,
publishSdkSession,
forceReloadSdkSession,
reloadPreview,
domEditSaveTimestampRef,
recordEdit,
onToggleElementHidden,
}: StudioRightPanelProps) {
const {
rightWidth,
setRightWidth,
rightPanelTab,
setRightPanelTab,
rightInspectorPanes,
toggleRightInspectorPane,
setExclusiveRightInspectorPane,
handlePanelResizeStart,
handlePanelResizeMove,
handlePanelResizeEnd,
} = usePanelLayoutContext();
const {
previewIframeRef,
projectId,
activeCompPath,
showToast,
compositionDimensions,
waitForPendingDomEditSaves,
renderQueue,
} = useStudioShellContext();
const { captionEditMode, refreshKey } = useStudioPlaybackContext();
const {
domEditSelection,
domEditGroupSelections,
copiedAgentPrompt,
clearDomSelection,
handleUngroupSelection,
handleGroupSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleDomTextCommit,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleAskAgent,
selectedGsapAnimations,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
commitAnimatedProperty,
commitAnimatedProperties,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
handleUpdateKeyframeEase,
handleSetAllKeyframeEases,
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
} = useDomEditContext();
const {
assets,
fontAssets,
projectDir,
handleImportFiles,
handleImportFonts,
refreshFileTree,
readProjectFile,
writeProjectFile,
fileTree,
editingFile,
} = useFileManagerContext();
// Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately,
// no coalescing — each is a distinct user action that deserves its own undo entry.
const onPersistSlideshow = useSlideshowPersist({
sdkSession,
activeCompPath,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
publishSdkSession,
});
// Notes path: persists are debounced in SlideshowPanel; coalesceKey ensures
// rapid writes collapse into a single undo entry via the save-queue infra.
const onPersistSlideshowNotes = useSlideshowPersist({
sdkSession,
activeCompPath,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
publishSdkSession,
coalesceKey: activeCompPath ? `slideshow-notes:${activeCompPath}` : "slideshow-notes",
});
const {
layersPanePercent,
splitContainerRef,
handleInspectorSplitResizeStart,
handleInspectorSplitResizeMove,
handleInspectorSplitResizeEnd,
} = useInspectorSplitResize();
const backgroundRemovalAbortRef = useRef<AbortController | null>(null);
useEffect(
() => () => {
backgroundRemovalAbortRef.current?.abort();
},
[],
);
const renderJobs = renderQueue.jobs as RenderJob[];
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
const { isSlideshowComposition, slideshowScenes } = useSlideshowTabState({
editingFileContent: editingFile?.content,
previewIframeRef,
refreshKey,
rightPanelTab,
setRightPanelTab,
});
const designPaneOpen = inspectorTabActive && rightInspectorPanes.design && designPanelActive;
const layersPaneOpen =
inspectorTabActive && rightInspectorPanes.layers && STUDIO_INSPECTOR_PANELS_ENABLED;
const handleInspectorPaneButtonClick = (pane: "design" | "layers") => {
if (!inspectorTabActive) {
setRightPanelTab(pane);
return;
}
// Flat inspector: Layers always renders full-height by itself (see the
// render branch below), so the two panes are mutually exclusive here —
// otherwise both tabs could show "active" while only one actually shows.
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
setExclusiveRightInspectorPane(pane);
return;
}
toggleRightInspectorPane(pane);
};
const handleApplyColorGradingScope = useCallback(
async (scope: ColorGradingScope, value: string | null) =>
applyColorGradingScopeUpdate({
scope,
value,
selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html",
fileTree,
projectId,
domEditSaveTimestampRef,
waitForPendingDomEditSaves,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
showToast,
}).catch((error) => {
showToast(
`Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`,
"error",
);
return EMPTY_COLOR_GRADING_SCOPE_RESULT;
}),
[
activeCompPath,
domEditSaveTimestampRef,
domEditSelection?.sourceFile,
fileTree,
projectId,
readProjectFile,
recordEdit,
reloadPreview,
showToast,
waitForPendingDomEditSaves,
writeProjectFile,
],
);
const handleRemoveBackground = useCallback(
// fallow-ignore-next-line complexity
async (
inputPath: string,
options: {
createBackgroundPlate?: boolean;
quality?: "fast" | "balanced" | "best";
onProgress?: (progress: BackgroundRemovalProgress) => void;
},
) => {
const response = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inputPath,
createBackgroundPlate: options.createBackgroundPlate === true,
quality: options.quality ?? "balanced",
}),
},
);
const data = (await response.json().catch(() => ({}))) as {
jobId?: string;
error?: string;
};
if (!response.ok || !data.jobId) {
throw new Error(data.error || `Background removal failed (${response.status})`);
}
showToast("Removing background...", "info");
backgroundRemovalAbortRef.current?.abort();
const controller = new AbortController();
backgroundRemovalAbortRef.current = controller;
try {
const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
await refreshFileTree();
showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
return result;
} finally {
if (backgroundRemovalAbortRef.current === controller) {
backgroundRemovalAbortRef.current = null;
}
}
},
[projectId, refreshFileTree, showToast],
);
const handleHideAllSelected = () => {
const { elements } = usePlayerStore.getState();
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
if (keys.length > 0) void onToggleElementHidden?.(keys, true);
};
const propertyPanel = (
<DesignPanelPromoteProvider
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
projectId={projectId}
activeCompPath={activeCompPath}
showToast={showToast}
readProjectFile={readProjectFile}
writeProjectFile={writeProjectFile}
recordEdit={recordEdit}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
forceReloadSharedSdkSession={forceReloadSdkSession}
>
<PropertyPanel
projectId={projectId}
projectDir={projectDir}
assets={assets}
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
multiSelectCount={domEditGroupSelections.length}
multiSelectedElements={domEditGroupSelections}
onGroupSelection={handleGroupSelection}
onHideAllSelected={handleHideAllSelected}
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
onToggleElementHidden={onToggleElementHidden}
onUngroup={handleUngroupSelection}
onSetStyle={handleDomStyleCommit}
onSetAttribute={handleDomAttributeCommit}
onSetAttributes={handleDomAttributesCommit}
onSetAttributeLive={handleDomAttributeLiveCommit}
onApplyColorGradingScope={handleApplyColorGradingScope}
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
onRemoveBackground={handleRemoveBackground}
onSetManualOffset={handleDomPathOffsetCommit}
onSetManualSize={handleDomBoxSizeCommit}
onSetManualRotation={handleDomRotationCommit}
onSetText={handleDomTextCommit}
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
onAddTextField={handleDomAddTextField}
onRemoveTextField={handleDomRemoveTextField}
onAskAgent={handleAskAgent}
onImportAssets={handleImportFiles}
fontAssets={fontAssets}
onImportFonts={handleImportFonts}
previewIframeRef={previewIframeRef}
gsapAnimations={selectedGsapAnimations}
gsapMultipleTimelines={gsapMultipleTimelines}
gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
onUpdateGsapProperty={handleGsapUpdateProperty}
onUpdateGsapMeta={handleGsapUpdateMeta}
onDeleteGsapAnimation={handleGsapDeleteAnimation}
onAddGsapProperty={handleGsapAddProperty}
onRemoveGsapProperty={handleGsapRemoveProperty}
onUpdateGsapFromProperty={handleGsapUpdateFromProperty}
onAddGsapFromProperty={handleGsapAddFromProperty}
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
onAddGsapAnimation={handleGsapAddAnimation}
onCommitAnimatedProperty={commitAnimatedProperty}
onCommitAnimatedProperties={commitAnimatedProperties}
onAddKeyframe={handleGsapAddKeyframe}
onRemoveKeyframe={handleGsapRemoveKeyframe}
onConvertToKeyframes={(animId, duration) =>
handleGsapConvertToKeyframes(animId, undefined, duration)
}
onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
onSetArcPath={handleSetArcPath}
onUpdateArcSegment={handleUpdateArcSegment}
onUnroll={handleUnroll}
onUpdateKeyframeEase={handleUpdateKeyframeEase}
onSetAllKeyframeEases={handleSetAllKeyframeEases}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
</DesignPanelPromoteProvider>
);
const renderQueuePanel = (
<RenderQueue
jobs={renderJobs}
projectId={projectId}
onDelete={renderQueue.deleteRender}
onCancel={renderQueue.cancelRender}
loadError={renderQueue.loadError}
onRetryLoad={renderQueue.reloadRenders}
actionError={renderQueue.actionError}
onDismissActionError={renderQueue.dismissActionError}
onClearCompleted={renderQueue.clearCompleted}
onStartRender={async (format, quality, resolution, fps) => {
await waitForPendingDomEditSaves();
const composition =
activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined;
await renderQueue.startRender({
fps,
quality,
format,
resolution,
composition,
// Render what the user is previewing: active variable overrides
// from the Variables panel ride along (undefined = defaults).
variables: usePreviewVariablesStore.getState().values ?? undefined,
});
}}
compositionDimensions={compositionDimensions}
isRendering={renderQueue.isRendering}
/>
);
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 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}
onPointerUp={handlePanelResizeEnd}
onPointerCancel={handlePanelResizeEnd}
onKeyDown={(e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
// Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it.
const delta = e.key === "ArrowLeft" ? 16 : -16;
setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
}}
>
{/* 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 rounded-lg border border-neutral-800 bg-neutral-900"
style={{ width: rightWidth }}
>
{captionEditMode ? (
<CaptionPropertyPanel iframeRef={previewIframeRef} />
) : (
<>
<div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
{STUDIO_INSPECTOR_PANELS_ENABLED && (
<>
<PanelTabButton
label="Design"
tooltip="Element styles and properties"
active={designPaneOpen}
onClick={() => handleInspectorPaneButtonClick("design")}
/>
<PanelTabButton
label="Layers"
tooltip="Composition layer stack"
active={layersPaneOpen}
onClick={() => handleInspectorPaneButtonClick("layers")}
/>
</>
)}
<PanelTabButton
label={renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
tooltip="Render queue and exports"
active={rightPanelTab === "renders"}
onClick={() => setRightPanelTab("renders")}
/>
{isSlideshowComposition && (
<PanelTabButton
label="Slideshow"
tooltip="Slideshow branching editor"
active={rightPanelTab === "slideshow"}
onClick={() => setRightPanelTab("slideshow")}
/>
)}
<PanelTabButton
label="Variables"
tooltip="Template variables — declare, preview with values"
active={rightPanelTab === "variables"}
onClick={() => setRightPanelTab("variables")}
/>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
{rightPanelTab === "block-params" && activeBlockParams ? (
<BlockParamsPanel
blockName={activeBlockParams.blockName}
blockTitle={activeBlockParams.blockTitle}
params={activeBlockParams.params}
compositionPath={activeBlockParams.compositionPath}
onClose={onCloseBlockParams ?? (() => {})}
/>
) : rightPanelTab === "slideshow" && isSlideshowComposition ? (
<SlideshowPanel
scenes={slideshowScenes}
onPersist={onPersistSlideshow}
onPersistNotes={onPersistSlideshowNotes}
/>
) : rightPanelTab === "variables" ? (
<VariablesPanel
sdkSession={sdkSession}
publishSdkSession={publishSdkSession}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
recordEdit={recordEdit}
/>
) : layersPaneOpen && designPaneOpen && !STUDIO_FLAT_INSPECTOR_ENABLED ? (
<div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
<div
className="min-h-[120px] overflow-hidden"
style={{ flexBasis: `${layersPanePercent}%`, flexShrink: 0 }}
>
<LayersPanel />
</div>
<div
role="separator"
aria-label="Resize Layers and Design panes"
aria-orientation="horizontal"
className="group flex h-2 flex-shrink-0 cursor-row-resize items-center justify-center border-y border-neutral-800 bg-neutral-900"
style={{ touchAction: "none" }}
onPointerDown={handleInspectorSplitResizeStart}
onPointerMove={handleInspectorSplitResizeMove}
onPointerUp={handleInspectorSplitResizeEnd}
onPointerCancel={handleInspectorSplitResizeEnd}
>
<div className="h-px w-10 rounded-full bg-white/12 transition-colors group-hover:bg-white/24 group-active:bg-studio-accent/70" />
</div>
<div className="min-h-0 flex-1 overflow-hidden">{propertyPanel}</div>
</div>
) : layersPaneOpen ? (
<LayersPanel />
) : designPaneOpen ? (
propertyPanel
) : inspectorTabActive ? (
// Inspector tab selected but no pane can render (panes toggled
// off, or inspector inactive during playback/recording): show an
// explanation instead of silently rendering the render queue
// under a highlighted inspector tab.
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="text-xs text-neutral-500">
Inspector is unavailable right now select the Design or Layers pane above, or
pause playback/recording to inspect elements.
</p>
<button
type="button"
onClick={() => setRightPanelTab("renders")}
className="h-7 rounded-md border border-neutral-800 px-3 text-[11px] font-medium text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200 active:scale-[0.98]"
>
Show Renders
</button>
</div>
) : (
renderQueuePanel
)}
</div>
</>
)}
</div>
</>
);
}