/**
* SlideshowSubPanels — internal sub-surface components for SlideshowPanel.
* Not exported from the package index; used only by SlideshowPanel.tsx.
*/
import { useState, useCallback, useId } from "react";
import type { SlideRef, SlideHotspot, SlideSequence } from "@hyperframes/core/slideshow";
import type { DomEditSelection } from "../editor/domEditing";
import type { SceneInfo } from "./slideshowPanelHelpers";
import { generateId } from "../../utils/generateId";
// ── Section header (accordion toggle) ────────────────────────────────────
export function SectionHeader({
children,
expanded,
onToggle,
}: {
children: React.ReactNode;
expanded: boolean;
onToggle: () => void;
}) {
return (
);
}
// ── Sub-surface: Slide List ──────────────────────────────────────────────
export interface SlideListProps {
scenes: SceneInfo[];
slides: SlideRef[];
selectedSceneId: string | null;
onSelect: (sceneId: string) => void;
onToggle: (sceneId: string) => void;
onReorder: (sceneId: string, dir: "up" | "down") => void;
}
export function SlideList({
scenes,
slides,
selectedSceneId,
onSelect,
onToggle,
onReorder,
}: SlideListProps) {
const slideIds = new Set(slides.map((s) => s.sceneId));
const sceneById = new Map(scenes.map((s) => [s.id, s]));
const orderedSlideScenes = slides
.map((sl) => sceneById.get(sl.sceneId))
.filter((s): s is SceneInfo => s !== undefined);
const nonSlideScenes = scenes.filter((sc) => !slideIds.has(sc.id));
const rows = [...orderedSlideScenes, ...nonSlideScenes];
return (
{rows.map((scene) => {
const isSlide = slideIds.has(scene.id);
const isSelected = selectedSceneId === scene.id;
return (
onSelect(scene.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(scene.id);
}
}}
>
onToggle(scene.id)}
onClick={(e) => e.stopPropagation()}
className="accent-studio-accent flex-shrink-0"
/>
{scene.label || scene.id}
{isSlide && (
)}
);
})}
{scenes.length === 0 && (
No scenes found
)}
);
}
// ── Sub-surface: Slide Inspector ─────────────────────────────────────────
export interface SlideInspectorProps {
sceneId: string;
slide: SlideRef | undefined;
currentTime: number;
onSetNotes: (notes: string) => void;
onMarkFragment: () => void;
onRemoveFragment: (time: number) => void;
}
// fallow-ignore-next-line complexity
export function SlideInspector({
sceneId,
slide,
currentTime,
onSetNotes,
onMarkFragment,
onRemoveFragment,
}: SlideInspectorProps) {
const fragments = slide?.fragments ?? [];
return (
Scene: {sceneId}
Fragment hold-points
{fragments.length > 0 ? (
{fragments.map((t, i) => (
{t.toFixed(2)}s
))}
) : (
No hold-points yet
)}
);
}
// ── Sub-surface: Branch Tree ──────────────────────────────────────────────
export interface BranchTreeProps {
sequences: SlideSequence[];
scenes: SceneInfo[];
onCreateSequence: (label: string) => void;
onRenameSequence: (id: string, label: string) => void;
onDeleteSequence: (id: string) => void;
onAssign: (sequenceId: string, sceneId: string, assign: boolean) => void;
selectedSceneId: string | null;
selectedSequenceId: string | null;
onSelectBranchSlide: (sequenceId: string, sceneId: string) => void;
}
export function BranchTree({
sequences,
scenes,
onCreateSequence,
onRenameSequence,
onDeleteSequence,
onAssign,
selectedSceneId,
selectedSequenceId,
onSelectBranchSlide,
}: BranchTreeProps) {
const [newLabel, setNewLabel] = useState("");
const inputId = useId();
const handleCreate = useCallback(() => {
const label = newLabel.trim();
if (!label) return;
onCreateSequence(label);
setNewLabel("");
}, [newLabel, onCreateSequence]);
return (
setNewLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
aria-label="New branch sequence name"
/>
{sequences.length === 0 ? (
No branches yet
) : (
{sequences.map((seq) => (
))}
)}
);
}
interface BranchItemProps {
seq: SlideSequence;
scenes: SceneInfo[];
onRename: (id: string, label: string) => void;
onDelete: (id: string) => void;
onAssign: (sequenceId: string, sceneId: string, assign: boolean) => void;
selectedSceneId: string | null;
selectedSequenceId: string | null;
onSelectBranchSlide: (sequenceId: string, sceneId: string) => void;
}
function BranchItem({
seq,
scenes,
onRename,
onDelete,
onAssign,
selectedSceneId,
selectedSequenceId,
onSelectBranchSlide,
}: BranchItemProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(seq.label);
const commitRename = useCallback(() => {
const label = draft.trim();
if (label && label !== seq.label) onRename(seq.id, label);
setEditing(false);
}, [draft, onRename, seq.id, seq.label]);
return (
{editing ? (
setDraft(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") setEditing(false);
}}
aria-label={`Rename branch ${seq.label}`}
/>
) : (
setEditing(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setEditing(true);
}}
>
{seq.label}
)}
{scenes.map((scene) => {
const assigned = seq.slides.some((s) => s.sceneId === scene.id);
const isSelected = selectedSequenceId === seq.id && selectedSceneId === scene.id;
return (
onAssign(seq.id, scene.id, e.target.checked)}
className="accent-studio-accent flex-shrink-0"
/>
{assigned ? (
) : (
{scene.label || scene.id}
)}
);
})}
{scenes.length === 0 &&
No scenes
}
);
}
// ── Sub-surface: Hotspot Tool ─────────────────────────────────────────────
export interface HotspotToolProps {
selectedSceneId: string | null;
slide: SlideRef | undefined;
domEditSelection: DomEditSelection | null;
sequences: SlideSequence[];
onAddHotspot: (sceneId: string, hotspot: SlideHotspot) => void;
onRemoveHotspot: (sceneId: string, hotspotId: string) => void;
}
// fallow-ignore-next-line complexity
export function HotspotTool({
selectedSceneId,
slide,
domEditSelection,
sequences,
onAddHotspot,
onRemoveHotspot,
}: HotspotToolProps) {
const [targetSequenceId, setTargetSequenceId] = useState("");
const [hotspotLabel, setHotspotLabel] = useState("");
const hotspots = slide?.hotspots ?? [];
const selectedElementId = domEditSelection?.element?.id ?? null;
const selectedHfId = domEditSelection?.hfId ?? null;
const elementKey = selectedElementId || selectedHfId;
// fallow-ignore-next-line complexity
const handleMakeHotspot = useCallback(() => {
if (!selectedSceneId || !targetSequenceId || !elementKey) return;
const id = `hotspot-${elementKey}-${generateId()}`;
const label = hotspotLabel.trim() || elementKey;
onAddHotspot(selectedSceneId, { id, label, target: targetSequenceId });
setHotspotLabel("");
}, [selectedSceneId, targetSequenceId, elementKey, hotspotLabel, onAddHotspot]);
if (!selectedSceneId) {
return (
Select a scene in the Slides list
);
}
return (
{hotspots.length > 0 && (
Hotspots on this slide
{hotspots.map((h) => {
const seqLabel = sequences.find((s) => s.id === h.target)?.label ?? h.target;
return (
{h.label} → {seqLabel}
);
})}
)}
);
}