diff --git a/bun.lock b/bun.lock index a4ced5562d..94e35a703b 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/studio/package.json b/packages/studio/package.json index e5198ec210..2a1e626338 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -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": { diff --git a/packages/studio/src/components/editor/SourceEditor.tsx b/packages/studio/src/components/editor/SourceEditor.tsx index b64a53926b..e99c48de79 100644 --- a/packages/studio/src/components/editor/SourceEditor.tsx +++ b/packages/studio/src/components/editor/SourceEditor.tsx @@ -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 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) { diff --git a/packages/studio/src/components/storyboard/StoryboardLoaded.tsx b/packages/studio/src/components/storyboard/StoryboardLoaded.tsx new file mode 100644 index 0000000000..75240ed49a --- /dev/null +++ b/packages/studio/src/components/storyboard/StoryboardLoaded.tsx @@ -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("board"); + const [sourceDirty, setSourceDirty] = useState(false); + const sourceFiles = useMemo(() => { + 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 ( +
+
+ +
+ {subView === "board" ? ( +
+
+ +
+ +
+ + {data.script && } +
+
+ ) : ( + + )} +
+ ); +} + +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 ( +
+ {SUB_VIEWS.map((option) => ( + + ))} +
+ ); +} diff --git a/packages/studio/src/components/storyboard/StoryboardSourceEditor.tsx b/packages/studio/src/components/storyboard/StoryboardSourceEditor.tsx new file mode 100644 index 0000000000..28023a6eb5 --- /dev/null +++ b/packages/studio/src/components/storyboard/StoryboardSourceEditor.tsx @@ -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(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 ( +
{ + if (!isSaveShortcut(e)) return; + e.preventDefault(); + if (file.dirty && !file.saving) file.save(); + }} + > +
+ {files.map((f) => ( + + ))} +
+ {file.error && {file.error}} + + {file.saving ? "Saving…" : file.dirty ? "Unsaved changes" : "Saved"} + + +
+
+
+
+ {file.loading ? ( +
Loading {activePath}…
+ ) : ( + + )} +
+
+
+
+
+
+ ); +} diff --git a/packages/studio/src/components/storyboard/StoryboardView.tsx b/packages/studio/src/components/storyboard/StoryboardView.tsx index 3d8a922647..1f0ca7f361 100644 --- a/packages/studio/src/components/storyboard/StoryboardView.tsx +++ b/packages/studio/src/components/storyboard/StoryboardView.tsx @@ -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 {Loading storyboard…}; if (error) { @@ -35,16 +32,7 @@ export function StoryboardView({ projectId }: StoryboardViewProps) { ); } - return ( - - -
- -
- - {data.script && } -
- ); + return ; } function StoryboardFrame({ children }: { children: ReactNode }) {