mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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
@@ -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