feat(studio): storyboard markdown source editor (raw + live preview) (#1531)

Fourth PR in the Studio storyboarding stack. Adds an in-context way to view
and edit the storyboard's canonical files.

- Board | Source sub-toggle inside the storyboard view (StoryboardLoaded).
- StoryboardSourceEditor: raw CodeMirror markdown editor + live rendered
  preview (marked), with a file switcher for STORYBOARD.md and SCRIPT.md.
- Loads raw file text and saves via the existing files API
  (GET/PUT /projects/:id/files/*); on save the Board re-parses (reload), so
  markdown stays the single source of truth. Cmd/Ctrl+S to save.
- Deliberately raw, not WYSIWYG, so the structured frame fields can't be
  mangled.
- SourceEditor gains markdown language support (@codemirror/lang-markdown);
  adds the marked dependency for preview.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-06-17 14:59:40 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 13af5540c1
commit 29809069c8
6 changed files with 354 additions and 44 deletions
+26 -11
View File
@@ -22,7 +22,7 @@
},
"packages/aws-lambda": {
"name": "@hyperframes/aws-lambda",
"version": "0.6.104",
"version": "0.6.109",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0",
@@ -54,7 +54,7 @@
},
"packages/cli": {
"name": "@hyperframes/cli",
"version": "0.6.104",
"version": "0.6.109",
"bin": {
"hyperframes": "./dist/cli.js",
},
@@ -101,7 +101,7 @@
},
"packages/core": {
"name": "@hyperframes/core",
"version": "0.6.104",
"version": "0.6.109",
"dependencies": {
"@babel/parser": "^7.27.0",
"@chenglou/pretext": "^0.0.5",
@@ -135,7 +135,7 @@
},
"packages/engine": {
"name": "@hyperframes/engine",
"version": "0.6.104",
"version": "0.6.109",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^",
@@ -153,7 +153,7 @@
},
"packages/gcp-cloud-run": {
"name": "@hyperframes/gcp-cloud-run",
"version": "0.6.104",
"version": "0.6.109",
"dependencies": {
"@google-cloud/storage": "^7.14.0",
"@google-cloud/workflows": "^4.2.0",
@@ -173,7 +173,7 @@
},
"packages/player": {
"name": "@hyperframes/player",
"version": "0.6.104",
"version": "0.6.109",
"devDependencies": {
"@types/bun": "^1.1.0",
"gsap": "^3.12.5",
@@ -185,7 +185,7 @@
},
"packages/producer": {
"name": "@hyperframes/producer",
"version": "0.6.104",
"version": "0.6.109",
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
@@ -226,7 +226,7 @@
},
"packages/sdk": {
"name": "@hyperframes/sdk",
"version": "0.6.104",
"version": "0.6.109",
"dependencies": {
"@hyperframes/core": "workspace:*",
"linkedom": "^0.18.12",
@@ -239,7 +239,7 @@
},
"packages/sdk-playground": {
"name": "@hyperframes/sdk-playground",
"version": "0.6.103",
"version": "0.6.106",
"dependencies": {
"@hyperframes/core": "workspace:*",
"@hyperframes/sdk": "workspace:*",
@@ -251,7 +251,7 @@
},
"packages/shader-transitions": {
"name": "@hyperframes/shader-transitions",
"version": "0.6.104",
"version": "0.6.109",
"dependencies": {
"html2canvas": "^1.4.1",
},
@@ -263,13 +263,14 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
"version": "0.6.104",
"version": "0.6.109",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-markdown": "^6.3.4",
"@codemirror/language": "^6.12.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
@@ -280,6 +281,8 @@
"@hyperframes/sdk": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
"bpm-detective": "^2.0.5",
"dompurify": "^3.2.4",
"marked": "^14.1.4",
"mediabunny": "^1.45.3",
},
"devDependencies": {
@@ -456,6 +459,8 @@
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
"@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="],
"@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
"@codemirror/lint": ["@codemirror/lint@6.9.6", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A=="],
@@ -750,6 +755,8 @@
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
"@lezer/markdown": ["@lezer/markdown@1.6.4", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
@@ -1076,6 +1083,8 @@
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
@@ -1328,6 +1337,8 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.4.10", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
@@ -1652,6 +1663,8 @@
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"marked": ["marked@14.1.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg=="],
"matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -2092,6 +2105,8 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@codemirror/lang-markdown/@codemirror/view": ["@codemirror/view@6.42.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg=="],
"@codemirror/lint/@codemirror/view": ["@codemirror/view@6.42.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg=="],
"@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
+3
View File
@@ -32,6 +32,7 @@
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-markdown": "^6.3.4",
"@codemirror/language": "^6.12.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
@@ -42,6 +43,8 @@
"@hyperframes/sdk": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
"bpm-detective": "^2.0.5",
"dompurify": "^3.2.4",
"marked": "^14.1.4",
"mediabunny": "^1.45.3"
},
"devDependencies": {
@@ -12,26 +12,26 @@ import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
import { oneDark } from "@codemirror/theme-one-dark";
import type { Extension } from "@codemirror/state";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
function getLanguageExtension(language: string) {
switch (language) {
case "html":
return html();
case "css":
return css();
case "javascript":
case "js":
case "typescript":
case "ts":
return javascript({
typescript: language === "typescript" || language === "ts",
});
default:
return html();
}
const LANGUAGE_EXTENSIONS: Record<string, () => Extension> = {
html: () => html(),
css: () => css(),
markdown: () => markdown(),
md: () => markdown(),
javascript: () => javascript(),
js: () => javascript(),
typescript: () => javascript({ typescript: true }),
ts: () => javascript({ typescript: true }),
};
function getLanguageExtension(language: string): Extension {
const factory = LANGUAGE_EXTENSIONS[language] ?? html;
return factory();
}
function detectLanguage(filePath: string): string {
@@ -45,6 +45,8 @@ function detectLanguage(filePath: string): string {
jsx: "javascript",
tsx: "typescript",
json: "javascript",
md: "markdown",
markdown: "markdown",
};
return map[ext] ?? "html";
}
@@ -74,6 +76,7 @@ export const SourceEditor = memo(function SourceEditor({
const contentRef = useRef(content);
contentRef.current = content;
// fallow-ignore-next-line complexity
const mountEditor = useCallback(
(node: HTMLDivElement | null) => {
if (editorRef.current) {
@@ -0,0 +1,95 @@
import { useMemo, useState } from "react";
import type { StoryboardResponse } from "../../hooks/useStoryboard";
import { StoryboardDirection } from "./StoryboardDirection";
import { StoryboardGrid } from "./StoryboardGrid";
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
type SubView = "board" | "source";
export interface StoryboardLoadedProps {
projectId: string;
data: StoryboardResponse;
/** Re-fetch the manifest after a source edit is saved. */
reload: () => void;
}
/** A storyboard that exists on disk: Board (contact sheet) ↔ Source (markdown editor). */
export function StoryboardLoaded({ projectId, data, reload }: StoryboardLoadedProps) {
const [subView, setSubView] = useState<SubView>("board");
const [sourceDirty, setSourceDirty] = useState(false);
const sourceFiles = useMemo<SourceFile[]>(() => {
const files: SourceFile[] = [{ path: data.path, label: data.path }];
if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
return files;
}, [data.path, data.script]);
// Leaving the source editor drops its in-memory buffer; confirm when it's dirty.
// fallow-ignore-next-line complexity
const changeSubView = (next: SubView) => {
if (next === subView) return;
if (
subView === "source" &&
sourceDirty &&
!window.confirm("Discard unsaved markdown changes?")
) {
return;
}
setSubView(next);
};
return (
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
<div className="flex items-center border-b border-neutral-800 px-4 py-2">
<SubViewToggle value={subView} onChange={changeSubView} />
</div>
{subView === "board" ? (
<div className="flex-1 min-h-0 overflow-auto">
<div className="mx-auto max-w-[1400px] px-8 py-8">
<StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
<div className="mt-5">
<StoryboardStatusLegend />
</div>
<StoryboardGrid projectId={projectId} frames={data.frames} />
{data.script && <StoryboardScriptPanel script={data.script} />}
</div>
</div>
) : (
<StoryboardSourceEditor
files={sourceFiles}
onSaved={reload}
onDirtyChange={setSourceDirty}
/>
)}
</div>
);
}
const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
{ value: "board", label: "Board" },
{ value: "source", label: "Source" },
];
function SubViewToggle({ value, onChange }: { value: SubView; onChange: (next: SubView) => void }) {
return (
<div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5" role="tablist">
{SUB_VIEWS.map((option) => (
<button
key={option.value}
type="button"
role="tab"
aria-selected={value === option.value}
onClick={() => onChange(option.value)}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
value === option.value
? "bg-neutral-700 text-neutral-100"
: "text-neutral-400 hover:text-neutral-200"
}`}
>
{option.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,206 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { SourceEditor } from "../editor/SourceEditor";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
export interface SourceFile {
path: string;
label: string;
}
export interface StoryboardSourceEditorProps {
files: SourceFile[];
/** Called after a successful save so the Board can re-parse the updated file. */
onSaved: () => void;
/** Surfaces unsaved-edit state so the parent can guard the Board↔Source toggle. */
onDirtyChange?: (dirty: boolean) => void;
}
const DISCARD_PROMPT = "Discard unsaved markdown changes?";
interface EditableFile {
content: string;
setContent: (next: string) => void;
dirty: boolean;
loading: boolean;
saving: boolean;
error: string | null;
save: () => void;
}
/** Load a project file's raw text and track edits + save state via the shared file manager. */
function useEditableFile(path: string, onSaved: () => void): EditableFile {
const { readProjectFile, writeProjectFile } = useFileManagerContext();
const [content, setContent] = useState("");
const [saved, setSaved] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!path) return;
let cancelled = false;
setLoading(true);
setError(null);
readProjectFile(path)
.then((text) => {
if (cancelled) return;
setContent(text);
setSaved(text);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : "failed to load file");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [path, readProjectFile]);
const save = useCallback(() => {
setSaving(true);
setError(null);
writeProjectFile(path, content)
.then(() => {
setSaved(content);
onSaved();
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : "failed to save"))
.finally(() => setSaving(false));
}, [writeProjectFile, path, content, onSaved]);
return { content, setContent, dirty: content !== saved, loading, saving, error, save };
}
/** Render markdown to sanitized HTML, debounced so we don't re-parse on every keystroke. */
function useMarkdownPreview(source: string): string {
const [debounced, setDebounced] = useState(source);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(source), 200);
return () => window.clearTimeout(id);
}, [source]);
return useMemo(() => {
const raw = marked.parse(debounced);
const html = typeof raw === "string" ? raw : "";
return DOMPurify.sanitize(html);
}, [debounced]);
}
function isSaveShortcut(event: React.KeyboardEvent): boolean {
return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
}
// Minimal prose styling for the rendered preview (Tailwind doesn't style raw HTML).
const PREVIEW_PROSE =
"text-sm leading-relaxed text-neutral-300 " +
"[&_h1]:mb-2 [&_h1]:mt-5 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:text-neutral-100 " +
"[&_h2]:mb-1.5 [&_h2]:mt-5 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-neutral-100 " +
"[&_h3]:mt-4 [&_h3]:font-semibold [&_h3]:text-neutral-200 " +
"[&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 " +
"[&_code]:rounded [&_code]:bg-neutral-800 [&_code]:px-1 [&_code]:text-[0.85em] [&_code]:text-neutral-200 " +
"[&_pre]:my-3 [&_pre]:overflow-auto [&_pre]:rounded [&_pre]:bg-neutral-900 [&_pre]:p-3 " +
"[&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
"[&_hr]:my-4 [&_hr]:border-neutral-800 [&_a]:text-sky-400 [&_strong]:text-neutral-100 " +
"[&_table]:my-3 [&_th]:border [&_th]:border-neutral-800 [&_th]:px-2 [&_th]:py-1 " +
"[&_td]:border [&_td]:border-neutral-800 [&_td]:px-2 [&_td]:py-1";
/**
* Raw markdown editor + live preview for the storyboard's canonical files
* (STORYBOARD.md / SCRIPT.md). Markdown stays the source of truth: saving writes
* the file and re-parses the Board. Deliberately raw (not WYSIWYG) so the
* structured frame fields can't be mangled; the preview is sanitized before render.
*/
// fallow-ignore-next-line complexity
export function StoryboardSourceEditor({
files,
onSaved,
onDirtyChange,
}: StoryboardSourceEditorProps) {
const [selected, setSelected] = useState(files[0]?.path ?? "");
// Reconcile against the current file list so a removed/renamed file can't strand the tab.
const activePath = files.some((f) => f.path === selected) ? selected : (files[0]?.path ?? "");
const file = useEditableFile(activePath, onSaved);
const previewHtml = useMarkdownPreview(file.content);
// Surface dirty state to the parent (guards the Board↔Source toggle) and warn
// on browser-level navigation while there are unsaved edits.
useEffect(() => onDirtyChange?.(file.dirty), [onDirtyChange, file.dirty]);
useEffect(() => {
if (!file.dirty) return;
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [file.dirty]);
// Switching files discards the in-memory buffer; confirm when there are unsaved edits.
const selectFile = (path: string) => {
if (path === activePath) return;
if (file.dirty && !window.confirm(DISCARD_PROMPT)) return;
setSelected(path);
};
return (
<div
className="flex flex-1 min-h-0 flex-col"
onKeyDown={(e) => {
if (!isSaveShortcut(e)) return;
e.preventDefault();
if (file.dirty && !file.saving) file.save();
}}
>
<div className="flex items-center gap-1 border-b border-neutral-800 px-4 py-2">
{files.map((f) => (
<button
key={f.path}
type="button"
onClick={() => selectFile(f.path)}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
activePath === f.path
? "bg-neutral-800 text-neutral-100"
: "text-neutral-400 hover:text-neutral-200"
}`}
>
{f.label}
</button>
))}
<div className="ml-auto flex items-center gap-3">
{file.error && <span className="text-xs text-red-400">{file.error}</span>}
<span className="text-xs text-neutral-500">
{file.saving ? "Saving…" : file.dirty ? "Unsaved changes" : "Saved"}
</span>
<button
type="button"
onClick={file.save}
disabled={!file.dirty || file.saving}
className="rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-40"
>
Save
</button>
</div>
</div>
<div className="flex flex-1 min-h-0">
<div className="w-1/2 min-w-0 border-r border-neutral-800">
{file.loading ? (
<div className="p-4 text-sm text-neutral-500">Loading {activePath}</div>
) : (
<SourceEditor
content={file.content}
language="markdown"
filePath={activePath}
onChange={file.setContent}
/>
)}
</div>
<div className="w-1/2 min-w-0 overflow-auto bg-neutral-950 px-6 py-4">
<div className={PREVIEW_PROSE} dangerouslySetInnerHTML={{ __html: previewHtml }} />
</div>
</div>
</div>
);
}
@@ -1,9 +1,6 @@
import type { ReactNode } from "react";
import { useStoryboard } from "../../hooks/useStoryboard";
import { StoryboardDirection } from "./StoryboardDirection";
import { StoryboardGrid } from "./StoryboardGrid";
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
import { StoryboardLoaded } from "./StoryboardLoaded";
export interface StoryboardViewProps {
projectId: string;
@@ -11,12 +8,12 @@ export interface StoryboardViewProps {
/**
* Top-level storyboard stage. Replaces the timeline/preview when the view mode
* is `storyboard`: renders the global direction plus the frame contact sheet,
* with loading / error / empty states.
* is `storyboard`. Handles the load states here; once a storyboard exists,
* {@link StoryboardLoaded} owns the Board ↔ Source experience.
*/
// fallow-ignore-next-line complexity
export function StoryboardView({ projectId }: StoryboardViewProps) {
const { data, loading, error } = useStoryboard(projectId);
const { data, loading, error, reload } = useStoryboard(projectId);
if (loading) return <StoryboardFrame>{<Message>Loading storyboard</Message>}</StoryboardFrame>;
if (error) {
@@ -35,16 +32,7 @@ export function StoryboardView({ projectId }: StoryboardViewProps) {
);
}
return (
<StoryboardFrame>
<StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
<div className="mt-5">
<StoryboardStatusLegend />
</div>
<StoryboardGrid projectId={projectId} frames={data.frames} />
{data.script && <StoryboardScriptPanel script={data.script} />}
</StoryboardFrame>
);
return <StoryboardLoaded projectId={projectId} data={data} reload={reload} />;
}
function StoryboardFrame({ children }: { children: ReactNode }) {