import { useCallback, useEffect, useState } from "react"; import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframes/core/storyboard"; import type { StoryboardFrameView } from "../../hooks/useStoryboard"; import { useFileManagerContext } from "../../contexts/FileManagerContext"; import { useViewMode } from "../../contexts/ViewModeContext"; import { FramePoster, posterTime } from "./FramePoster"; import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus"; export interface StoryboardFrameFocusProps { projectId: string; /** Path to STORYBOARD.md (edits are written here). */ storyboardPath: string; frame: StoryboardFrameView; frameCount: number; onBack: () => void; onNavigate: (delta: number) => void; /** Re-parse the manifest after an edit is saved. */ onSaved: () => void; /** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */ onSelectComposition: (path: string) => void; } /** * Full-area focus on a single frame: large poster, editable voiceover guide, * status advancement, full narrative, and a jump into the live preview. Edits * are written back to STORYBOARD.md in place (markdown stays canonical). * * Mounted with a `key` per frame, so `draft` initializes from the frame and a * save-triggered reload never clobbers in-progress typing. */ // fallow-ignore-next-line complexity export function StoryboardFrameFocus({ projectId, storyboardPath, frame, frameCount, onBack, onNavigate, onSaved, onSelectComposition, }: StoryboardFrameFocusProps) { const { readProjectFile, writeProjectFile } = useFileManagerContext(); const { setViewMode } = useViewMode(); const [draft, setDraft] = useState(frame.voiceover ?? ""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const applyEdit = useCallback( async (edit: (source: string) => string) => { if (busy) return; // one read-modify-write at a time; avoids a lost update setBusy(true); setError(null); try { const source = await readProjectFile(storyboardPath); await writeProjectFile(storyboardPath, edit(source)); onSaved(); } catch (err: unknown) { setError(err instanceof Error ? err.message : "failed to save"); } finally { setBusy(false); } }, [readProjectFile, writeProjectFile, storyboardPath, onSaved, busy], ); const title = frame.title ?? `Frame ${frame.index}`; const dirty = draft !== (frame.voiceover ?? ""); const canOpenPreview = frame.srcExists && Boolean(frame.src); // Leaving the frame drops the in-memory voiceover draft; confirm when it's dirty. const confirmLeave = () => !dirty || window.confirm("Discard unsaved voiceover changes?"); const handleBack = () => { if (confirmLeave()) onBack(); }; const handleNavigate = (delta: number) => { if (confirmLeave()) onNavigate(delta); }; // ←/→ navigate frames, Esc returns to the Board — but never while typing in a field. useEffect(() => { // fallow-ignore-next-line complexity const onKey = (e: KeyboardEvent) => { const el = document.activeElement; if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) return; if (e.key === "Escape") handleBack(); else if (e.key === "ArrowLeft" && frame.index > 1) handleNavigate(-1); else if (e.key === "ArrowRight" && frame.index < frameCount) handleNavigate(1); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }); const openInPreview = () => { if (frame.src) onSelectComposition(frame.src); setViewMode("timeline"); }; return (
Frame {frame.number ?? frame.index} — {title}
handleNavigate(-1)} /> = frameCount} onClick={() => handleNavigate(1)} />
{canOpenPreview && frame.src ? ( ) : (
{frame.status === "outline" ? "Not built yet" : "No preview"}
)}
applyEdit((src) => setFrameStatus(src, frame.index, s))} />
{frame.duration && Duration {frame.duration}} {frame.transitionIn && Transition {frame.transitionIn}}

🎙 Voiceover guide