mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
co-authored by
Claude Opus 4.8
parent
13af5540c1
commit
29809069c8
@@ -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=="],
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user