mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(studio): storyboard polish — a11y, preview, and edit-race fixes (#1544)
Batched non-blocking review nits from the storyboard stack (#1528–#1532):
- StoryboardGrid: responsive auto-fill grid instead of fixed-360 tiles
- FramePoster: reset failed state when the poster target changes (stale-error fix)
- StoryboardFrameTile: status-chip aria-label
- StoryboardSourceEditor: marked({async:false}); save() in-flight guard;
immediate first preview paint; [&_img] prose; scoped link-hardening
(rel=noopener noreferrer + target=_blank) in the sanitizer
- StoryboardLoaded: memoize sourceFiles on data.script.path/.exists, not the object ref
- StoryboardFrameFocus: applyEdit in-flight guard; aria-pressed on status buttons;
←/→/Esc keyboard navigation
- ViewModeContext: correct the popstate/replaceState doc-drift
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
c8fd16f2d3
commit
e662fcdea2
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
||||
|
||||
export interface FramePosterProps {
|
||||
@@ -20,6 +20,9 @@ export interface FramePosterProps {
|
||||
*/
|
||||
export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: FramePosterProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
// The <img> is reused (no key) when a tile/hero swaps to a different frame, so a
|
||||
// prior load error would stick. Reset when the poster target changes.
|
||||
useEffect(() => setFailed(false), [src, seconds]);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
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";
|
||||
@@ -47,6 +47,7 @@ export function StoryboardFrameFocus({
|
||||
|
||||
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 {
|
||||
@@ -59,7 +60,7 @@ export function StoryboardFrameFocus({
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[readProjectFile, writeProjectFile, storyboardPath, onSaved],
|
||||
[readProjectFile, writeProjectFile, storyboardPath, onSaved, busy],
|
||||
);
|
||||
|
||||
const title = frame.title ?? `Frame ${frame.index}`;
|
||||
@@ -75,6 +76,20 @@ export function StoryboardFrameFocus({
|
||||
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");
|
||||
@@ -229,6 +244,7 @@ function StatusRow({
|
||||
key={option}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
aria-pressed={status === option}
|
||||
title={FRAME_STATUS_META[option].tooltip}
|
||||
onClick={() => onSet(option)}
|
||||
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
|
||||
@@ -9,8 +9,6 @@ export interface StoryboardFrameTileProps {
|
||||
onOpen: (index: number) => void;
|
||||
}
|
||||
|
||||
const TILE_WIDTH = 360;
|
||||
|
||||
function firstLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
@@ -35,7 +33,7 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram
|
||||
const sceneLine = frame.scene ?? firstLine(frame.narrative);
|
||||
|
||||
return (
|
||||
<article style={{ width: TILE_WIDTH }}>
|
||||
<article className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(frame.index)}
|
||||
@@ -60,6 +58,7 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram
|
||||
<h3 className="truncate text-sm font-medium text-neutral-200">{title}</h3>
|
||||
<span
|
||||
title={meta.tooltip}
|
||||
aria-label={`Status: ${meta.label} — ${meta.tooltip}`}
|
||||
className={`shrink-0 cursor-default rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${meta.chipClass}`}
|
||||
>
|
||||
{meta.label}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function StoryboardGrid({ projectId, frames, onOpenFrame }: StoryboardGri
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-8">
|
||||
<div className="mt-8 grid gap-x-6 gap-y-8 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
|
||||
{frames.map((frame) => (
|
||||
<StoryboardFrameTile
|
||||
key={frame.index}
|
||||
|
||||
@@ -37,7 +37,9 @@ export function StoryboardLoaded({
|
||||
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]);
|
||||
// Depend on the stable fields, not the `data.script` object — every reload()
|
||||
// produces a fresh object and would needlessly re-create this array.
|
||||
}, [data.path, data.script?.path, data.script?.exists]);
|
||||
|
||||
// Leaving the source editor drops its in-memory buffer; confirm when it's dirty.
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { SourceEditor } from "../editor/SourceEditor";
|
||||
@@ -61,6 +61,7 @@ function useEditableFile(path: string, onSaved: () => void): EditableFile {
|
||||
}, [path, readProjectFile]);
|
||||
|
||||
const save = useCallback(() => {
|
||||
if (saving) return; // coalesce a fast double Cmd+S into one PUT
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
writeProjectFile(path, content)
|
||||
@@ -70,22 +71,45 @@ function useEditableFile(path: string, onSaved: () => void): EditableFile {
|
||||
})
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "failed to save"))
|
||||
.finally(() => setSaving(false));
|
||||
}, [writeProjectFile, path, content, onSaved]);
|
||||
}, [writeProjectFile, path, content, onSaved, saving]);
|
||||
|
||||
return { content, setContent, dirty: content !== saved, loading, saving, error, save };
|
||||
}
|
||||
|
||||
/** Preview links open in a new tab with the `window.opener` back-channel severed. */
|
||||
function hardenLinks(node: Element): void {
|
||||
if (node.tagName === "A" && node.hasAttribute("href")) {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
const primed = useRef(false);
|
||||
useEffect(() => {
|
||||
// Paint the first non-empty content immediately (no 200ms blank window after a file
|
||||
// loads), exactly once, then debounce all subsequent keystrokes.
|
||||
if (!primed.current && source !== "") {
|
||||
primed.current = true;
|
||||
setDebounced(source);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
// `{ async: false }` pins the synchronous string return (no Promise union to narrow).
|
||||
const html = marked.parse(debounced, { async: false });
|
||||
// Scope the link-hardening hook to this call; `finally` guarantees removal even if
|
||||
// `sanitize` throws, so the hook can never leak into other DOMPurify consumers.
|
||||
DOMPurify.addHook("afterSanitizeAttributes", hardenLinks);
|
||||
try {
|
||||
return DOMPurify.sanitize(html);
|
||||
} finally {
|
||||
DOMPurify.removeHook("afterSanitizeAttributes");
|
||||
}
|
||||
}, [debounced]);
|
||||
}
|
||||
|
||||
@@ -104,6 +128,7 @@ const PREVIEW_PROSE =
|
||||
"[&_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 " +
|
||||
"[&_img]:my-2 [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded " +
|
||||
"[&_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";
|
||||
|
||||
|
||||
@@ -53,7 +53,12 @@ export function useViewModeState(enabled: boolean): ViewModeValue {
|
||||
enabled ? readViewModeFromUrl() : "timeline",
|
||||
);
|
||||
|
||||
// Reflect back/forward navigation and agent-driven URL changes.
|
||||
// Reflect genuine browser back/forward between history entries with a different
|
||||
// `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
|
||||
// `popstate`, so this listener never sees them — `setViewMode` updates state directly.
|
||||
// An agent deep-links by doing a full navigation to `?view=storyboard` (picked up by
|
||||
// the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
|
||||
// reflected here, by design.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onPopState = () => setMode(readViewModeFromUrl());
|
||||
|
||||
Reference in New Issue
Block a user