feat(studio): slideshow branching editor panel (#1582)

New Slideshow right-panel tab — slide list, inspector (notes + fragment
hold-points), branch tree, hotspot tool — backed by pure manifest-transform
helpers and a debounced SDK persist that writes the JSON island.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-19 01:33:31 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7af3eb8f80
commit 3861e8e9fc
11 changed files with 1936 additions and 7 deletions
@@ -1,13 +1,26 @@
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import {
useCallback,
useMemo,
useRef,
useState,
type MutableRefObject,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Tooltip } from "./ui";
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 type { SceneInfo } from "./panels/SlideshowPanel";
import type { RenderJob } from "./renders/useRenderQueue";
import type { BlockParam } from "@hyperframes/core/registry";
import type { IframeWindow } from "../player/lib/playbackTypes";
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability";
import type { Composition } from "@hyperframes/sdk";
import type { EditHistoryKind } from "../utils/editHistory";
import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
@@ -30,6 +43,15 @@ export interface StudioRightPanelProps {
recordingState?: "idle" | "recording" | "preview";
recordingDuration?: number;
onToggleRecording?: () => void;
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
sdkSession: Composition | null;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
}
// fallow-ignore-next-line complexity
@@ -40,6 +62,10 @@ export function StudioRightPanel({
recordingState,
recordingDuration,
onToggleRecording,
sdkSession,
reloadPreview,
domEditSaveTimestampRef,
recordEdit,
}: StudioRightPanelProps) {
const {
rightWidth,
@@ -60,7 +86,7 @@ export function StudioRightPanel({
waitForPendingDomEditSaves,
renderQueue,
} = useStudioShellContext();
const { captionEditMode } = useStudioPlaybackContext();
const { captionEditMode, refreshKey } = useStudioPlaybackContext();
const {
domEditSelection,
@@ -100,8 +126,40 @@ export function StudioRightPanel({
handleGsapConvertToKeyframes,
} = useDomEditContext();
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
useFileManagerContext();
const {
assets,
fontAssets,
projectDir,
handleImportFiles,
handleImportFonts,
readProjectFile,
writeProjectFile,
} = 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,
});
// 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,
coalesceKey: activeCompPath ? `slideshow-notes:${activeCompPath}` : "slideshow-notes",
});
const [layersPanePercent, setLayersPanePercent] = useState(40);
const splitContainerRef = useRef<HTMLDivElement>(null);
@@ -113,6 +171,23 @@ export function StudioRightPanel({
const renderJobs = renderQueue.jobs as RenderJob[];
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
// Derive scene list from the live clip manifest in the preview iframe.
// fallow-ignore-next-line complexity
const slideshowScenes = useMemo<SceneInfo[]>(() => {
try {
const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
return (win?.__clipManifest?.scenes ?? []).map((s) => ({
id: s.id,
label: s.label,
start: s.start,
duration: s.duration,
}));
} catch {
return [];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewIframeRef, rightPanelTab, refreshKey]);
const designPaneOpen = inspectorTabActive && rightInspectorPanes.design && designPanelActive;
const layersPaneOpen =
inspectorTabActive && rightInspectorPanes.layers && STUDIO_INSPECTOR_PANELS_ENABLED;
@@ -291,6 +366,19 @@ export function StudioRightPanel({
{renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
</button>
</Tooltip>
<Tooltip label="Slideshow branching editor" side="bottom">
<button
type="button"
onClick={() => setRightPanelTab("slideshow")}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
rightPanelTab === "slideshow"
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
}`}
>
Slideshow
</button>
</Tooltip>
</div>
<div className="min-h-0 flex-1">
{rightPanelTab === "block-params" && activeBlockParams ? (
@@ -301,6 +389,12 @@ export function StudioRightPanel({
compositionPath={activeBlockParams.compositionPath}
onClose={onCloseBlockParams ?? (() => {})}
/>
) : rightPanelTab === "slideshow" ? (
<SlideshowPanel
scenes={slideshowScenes}
onPersist={onPersistSlideshow}
onPersistNotes={onPersistSlideshowNotes}
/>
) : layersPaneOpen && designPaneOpen ? (
<div ref={splitContainerRef} className="flex h-full min-h-0 flex-col">
<div