返回 JoyAI-Echo
StoryboardScriptEditor.tsx
根目录 / echo_longvideo / Director_Agent / webui / src / components / longvideo / StoryboardScriptEditor.tsx
1 import { useCallback, useRef } from "react";
2 import {
3 ArrowRight,
4 Download,
5 Save,
6 GripVertical,
7 Layout,
8 Merge,
9 Scissors,
10 } from "lucide-react";
11
12 import { Button } from "@/components/ui/button";
13 import { cn } from "@/lib/utils";
14
15 export interface StoryboardSegment {
16 id: string;
17 text: string;
18 }
19
20 export function downloadStoryboardSegments(segments: StoryboardSegment[]) {
21 const content = segments
22 .map((seg, i) => `[SHOT ${String(i + 1).padStart(2, "0")}]\n${seg.text}`)
23 .join("\n\n");
24 const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
25 const url = URL.createObjectURL(blob);
26 const a = document.createElement("a");
27 a.href = url;
28 a.download = "shot-plan.txt";
29 a.click();
30 URL.revokeObjectURL(url);
31 }
32
33 interface StoryboardScriptEditorProps {
34 segments: StoryboardSegment[];
35 onSegmentChange: (segmentId: string, text: string) => void;
36 onDeleteSegment: (segmentId: string) => void;
37 onApplyNext: () => void;
38 onAutoGenerate?: () => void;
39 autoGenerateLabel?: string;
40 onSplitAt?: (
41 segmentId: string,
42 cursorPos: number,
43 segmentText: string,
44 ) => void;
45 onMergeUp?: (lowerSegmentId: string, mergedText: string) => void;
46 onSegmentFocus?: (segmentId: string) => void;
47 onSegmentBlur?: (segmentId: string) => void;
48 applyNextDisabled?: boolean;
49 applyNextLabel?: string;
50 splitMergeDisabled?: boolean;
51 readOnly?: boolean;
52 onSave?: () => void | Promise<void>;
53 saveDisabled?: boolean;
54 saving?: boolean;
55 }
56
57 export function StoryboardScriptEditor({
58 segments,
59 onSegmentChange,
60 onDeleteSegment,
61 onApplyNext,
62 onSplitAt,
63 onMergeUp,
64 onSegmentFocus,
65 onSegmentBlur,
66 applyNextDisabled = false,
67 applyNextLabel = "Next Step",
68 onAutoGenerate,
69 autoGenerateLabel = "Approve & Auto Generate",
70 splitMergeDisabled = false,
71 readOnly = false,
72 onSave,
73 saveDisabled,
74 saving = false,
75 }: StoryboardScriptEditorProps) {
76 const updateText = useCallback(
77 (id: string, text: string) => {
78 onSegmentChange(id, text);
79 },
80 [onSegmentChange],
81 );
82
83 const splitAt = useCallback(
84 (id: string, cursorPos: number) => {
85 const seg = segments.find((s) => s.id === id);
86 if (!seg) return;
87 if (onSplitAt) {
88 onSplitAt(id, cursorPos, seg.text);
89 return;
90 }
91 const idx = segments.findIndex((s) => s.id === id);
92 if (idx === -1) return;
93 const before = seg.text.slice(0, cursorPos);
94 const after = seg.text.slice(cursorPos);
95 const newSeg: StoryboardSegment = {
96 id: `seg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
97 text: after.trimStart(),
98 };
99 const updated = [...segments];
100 updated[idx] = { ...seg, text: before.trimEnd() };
101 updated.splice(idx + 1, 0, newSeg);
102 onSegmentChange(id, before.trimEnd());
103 onSegmentChange(newSeg.id, newSeg.text);
104 },
105 [onSegmentChange, onSplitAt, segments],
106 );
107
108 const mergeUp = useCallback(
109 (idx: number) => {
110 if (idx <= 0) return;
111 const lower = segments[idx];
112 const upper = segments[idx - 1];
113 const mergedText = `${upper.text.trimEnd()}\n\n${lower.text.trimStart()}`;
114 if (onMergeUp) {
115 onMergeUp(lower.id, mergedText);
116 return;
117 }
118 const updated = [...segments];
119 updated[idx - 1] = {
120 ...updated[idx - 1],
121 text: mergedText,
122 };
123 updated.splice(idx, 1);
124 onSegmentChange(upper.id, mergedText);
125 },
126 [onMergeUp, onSegmentChange, segments],
127 );
128
129 const handleDownload = useCallback(() => {
130 downloadStoryboardSegments(segments);
131 }, [segments]);
132
133 if (segments.length === 0) {
134 return (
135 <div className="flex h-full flex-col items-center justify-center px-6 text-center">
136 <div className="relative mb-6">
137 <div className="absolute -inset-4 rounded-2xl bg-foreground/[0.02]" />
138 <div className="relative grid h-14 w-14 place-items-center rounded-2xl bg-foreground/[0.04] ring-1 ring-inset ring-foreground/[0.06]">
139 <Layout className="h-6 w-6 text-foreground/40" />
140 </div>
141 </div>
142 <p className="text-[15px] font-semibold text-foreground/90">
143 Building shot plan
144 </p>
145 <p className="mt-2 max-w-xs text-sm leading-relaxed text-muted-foreground">
146 The story is being divided into production-ready shots.
147 </p>
148 </div>
149 );
150 }
151
152 return (
153 <div className="flex h-full flex-col">
154 <div className="flex items-center justify-between border-b border-border/40 px-4 py-2">
155 <div className="flex items-center gap-2 text-[11px] text-muted-foreground">
156 <Scissors className="h-3 w-3" />
157 Shift+Enter Split
158 <span className="mx-1 text-border/60">|</span>
159 <Merge className="h-3 w-3" />
160 Hover center to merge
161 </div>
162 <div className="flex items-center gap-2">
163 <button
164 type="button"
165 onClick={() => void onSave?.()}
166 disabled={saveDisabled ?? readOnly ?? saving}
167 className={cn(
168 "flex items-center gap-1 rounded-md px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground",
169 "disabled:pointer-events-none disabled:opacity-40",
170 )}
171 >
172 <Save className="h-3 w-3" />
173 {saving ? "Saving..." : "Save"}
174 </button>
175 <button
176 type="button"
177 onClick={handleDownload}
178 className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground"
179 >
180 <Download className="h-3 w-3" />
181 Download
182 </button>
183 <span className="rounded-md bg-foreground/[0.05] px-2 py-0.5 text-[11px] tabular-nums text-muted-foreground">
184 {segments.length} shots
185 </span>
186 </div>
187 </div>
188
189 <div className="min-h-0 flex-1 overflow-y-auto px-4 py-3 scrollbar-thin">
190 <div className="space-y-0">
191 {segments.map((seg, idx) => (
192 <SegmentBlock
193 key={seg.id}
194 segment={seg}
195 index={idx}
196 total={segments.length}
197 readOnly={readOnly}
198 splitMergeDisabled={splitMergeDisabled}
199 onTextChange={(text) => updateText(seg.id, text)}
200 onFocus={() => onSegmentFocus?.(seg.id)}
201 onBlur={() => onSegmentBlur?.(seg.id)}
202 onSplit={(cursorPos) => splitAt(seg.id, cursorPos)}
203 onMergeUp={() => mergeUp(idx)}
204 onDeleteSegment={(segmentId) => onDeleteSegment(segmentId)}
205 />
206 ))}
207 </div>
208 </div>
209
210 {/* bottom action bar */}
211 <div className="shrink-0 border-t border-border/50 px-4 py-3">
212 {onAutoGenerate ? (
213 <div className="flex gap-2">
214 <Button
215 onClick={onAutoGenerate}
216 variant="outline"
217 disabled={segments.length === 0 || applyNextDisabled}
218 className={cn(
219 "w-full rounded-lg text-[13px] font-medium",
220 // "bg-foreground text-background",
221 // "hover:bg-foreground/90 active:scale-[0.99]",
222 "h-9 transition-all duration-200",
223 "disabled:opacity-40",
224 )}
225 >
226 {autoGenerateLabel}
227 <ArrowRight className="ml-1.5 h-3.5 w-3.5" />
228 </Button>
229 <Button
230 onClick={onApplyNext}
231 disabled={segments.length === 0 || applyNextDisabled}
232 variant="outline"
233 className={cn(
234 "w-full rounded-lg text-[13px] font-medium",
235 "h-9 transition-all duration-200",
236 "disabled:opacity-40",
237 )}
238 >
239 {applyNextLabel}
240 <ArrowRight className="ml-1.5 h-3.5 w-3.5" />
241 </Button>
242 </div>
243 ) : (
244 <Button
245 onClick={onApplyNext}
246 disabled={segments.length === 0 || applyNextDisabled}
247 className={cn(
248 "w-full rounded-lg text-[13px] font-medium",
249 "bg-foreground text-background",
250 "hover:bg-foreground/90 active:scale-[0.99]",
251 "h-9 transition-all duration-200",
252 "disabled:opacity-40",
253 )}
254 >
255 {applyNextLabel}
256 <ArrowRight className="ml-1.5 h-3.5 w-3.5" />
257 </Button>
258 )}
259 </div>
260 </div>
261 );
262 }
263
264 function SegmentBlock({
265 segment,
266 index,
267 total,
268 readOnly = false,
269 splitMergeDisabled = false,
270 onTextChange,
271 onFocus,
272 onBlur,
273 onSplit,
274 onMergeUp,
275 onDeleteSegment,
276 }: {
277 segment: StoryboardSegment;
278 index: number;
279 total: number;
280 readOnly?: boolean;
281 splitMergeDisabled?: boolean;
282 onTextChange: (text: string) => void;
283 onFocus?: () => void;
284 onBlur?: () => void;
285 onSplit: (cursorPos: number) => void;
286 onMergeUp: () => void;
287 onDeleteSegment: (segmentId: string) => void;
288 }) {
289 const textareaRef = useRef<HTMLTextAreaElement>(null);
290
291 const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
292 if (
293 readOnly ||
294 splitMergeDisabled ||
295 e.key !== "Enter" ||
296 !e.shiftKey ||
297 e.nativeEvent.isComposing
298 ) {
299 return;
300 }
301 e.preventDefault();
302 const pos = e.currentTarget.selectionStart;
303 onSplit(pos);
304 };
305
306 return (
307 <div className="group">
308 {index > 0 && (
309 <div className="relative flex items-center gap-2 py-1">
310 <div className="h-px flex-1 bg-border/50" />
311 <button
312 type="button"
313 onClick={onMergeUp}
314 disabled={splitMergeDisabled}
315 className="flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] text-muted-foreground opacity-0 transition-all duration-200 hover:bg-foreground/[0.06] hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
316 >
317 <Merge className="h-2.5 w-2.5" />
318 Merge
319 </button>
320 <div className="h-px flex-1 bg-border/50" />
321 </div>
322 )}
323 <div
324 className={cn(
325 "relative rounded-xl border border-border/40 bg-card/20 p-3 transition-all duration-200",
326 "focus-within:border-foreground/15 focus-within:bg-card/40 focus-within:shadow-sm",
327 )}
328 >
329 <div className="mb-1.5 flex items-center justify-between">
330 <div className="flex items-center gap-1.5">
331 <GripVertical className="h-3 w-3 text-muted-foreground/30" />
332 <span className="text-[10px] font-semibold tabular-nums tracking-wider text-muted-foreground/60">
333 Shot {String(index + 1).padStart(2, "0")}/
334 {String(total).padStart(2, "0")}
335 </span>
336 </div>
337 {/* <span className="text-[10px] tabular-nums text-muted-foreground/40">
338 {segment.text.length} chars
339 </span> */}
340 <span
341 className="text-[12px] tabular-nums text-muted-foreground/40 cursor-pointer hover:text-muted-foreground transition-all duration-200"
342 onClick={() => onDeleteSegment(segment.id)}
343 >
344 Delete
345 </span>
346 </div>
347 <textarea
348 ref={textareaRef}
349 value={segment.text}
350 readOnly={readOnly}
351 onFocus={onFocus}
352 onBlur={onBlur}
353 onChange={(e) => {
354 if (readOnly) return;
355 onTextChange(e.target.value);
356 e.target.style.height = "auto";
357 e.target.style.height = `${e.target.scrollHeight}px`;
358 }}
359 onKeyDown={onKeyDown}
360 rows={2}
361 className={cn(
362 "w-full resize-none bg-transparent text-[13px] leading-[1.85] text-foreground/88",
363 "focus:outline-none",
364 "min-h-[56px]",
365 )}
366 />
367 </div>
368 </div>
369 );
370 }
371
371 lines Plain Text