返回 JoyAI-Echo
ScriptEditor.tsx
1 import { useCallback, useRef, useState } from "react";
2 import { ArrowRight, Download, Save } from "lucide-react";
3
4 import { Button } from "@/components/ui/button";
5 import { cn } from "@/lib/utils";
6
7 const waitingCSS = `
8 @keyframes slate-fade-in {
9 from { opacity: 0; transform: translateY(12px); }
10 to { opacity: 1; transform: translateY(0); }
11 }
12 @keyframes status-pulse {
13 0%, 100% { opacity: 0.55; }
14 50% { opacity: 1; }
15 }
16 @keyframes bar-shimmer {
17 0% { transform: translateX(-100%); }
18 100% { transform: translateX(250%); }
19 }
20 `;
21
22 interface ScriptEditorProps {
23 value: string;
24 onChange: (text: string) => void;
25 onApplyNext: () => void;
26 applyNextDisabled?: boolean;
27 /** 底部主按钮文案;父组件在 workflow 等待期可传入进度提示(对齐 StoryboardScriptEditor)。 */
28 applyNextLabel?: string;
29 readOnly?: boolean;
30 onFocus?: () => void;
31 onBlur?: () => void;
32 onSave?: () => void | Promise<void>;
33 saveDisabled?: boolean;
34 saving?: boolean;
35 }
36
37 export function ScriptEditor({
38 value,
39 onChange,
40 onApplyNext,
41 applyNextDisabled = false,
42 applyNextLabel = "Next Step",
43 readOnly = false,
44 onFocus,
45 onBlur,
46 onSave,
47 saveDisabled,
48 saving = false,
49 }: ScriptEditorProps) {
50 const textareaRef = useRef<HTMLTextAreaElement>(null);
51 const [manualEdit] = useState(false);
52
53 const handleDownload = useCallback(() => {
54 const blob = new Blob([value], { type: "text/plain;charset=utf-8" });
55 const url = URL.createObjectURL(blob);
56 const a = document.createElement("a");
57 a.href = url;
58 a.download = "story-script.txt";
59 a.click();
60 URL.revokeObjectURL(url);
61 }, [value]);
62
63 const hasContent = value.trim().length > 0;
64 const showEditor = hasContent || manualEdit;
65
66 if (!showEditor) {
67 return (
68 <div className="flex h-full flex-col items-center justify-center px-10">
69 <style>{waitingCSS}</style>
70
71 {/* Clapperboard card */}
72 <div
73 className="w-full max-w-[320px] rounded-xl border border-border/70 bg-card/40 backdrop-blur-sm"
74 style={{ animation: "slate-fade-in 0.5s ease-out both" }}
75 >
76 {/* Slate top bar */}
77 <div className="flex items-center border-b border-border/50 px-5 py-3">
78 <span className="text-[13px] font-semibold uppercase tracking-[0.18em] text-foreground/50">
79 Production
80 </span>
81 </div>
82
83 {/* Fields */}
84 <div className="space-y-3 px-5 py-4">
85 <div className="flex items-baseline gap-3">
86 <span className="w-16 shrink-0 text-[12px] font-medium uppercase tracking-[0.14em] text-foreground/40">
87 Scene
88 </span>
89 <div className="h-px flex-1 bg-border/60" />
90 </div>
91 <div className="flex items-baseline gap-3">
92 <span className="w-16 shrink-0 text-[12px] font-medium uppercase tracking-[0.14em] text-foreground/40">
93 Take
94 </span>
95 <div className="h-px flex-1 bg-border/60" />
96 </div>
97 <div className="flex items-baseline gap-3">
98 <span className="w-16 shrink-0 text-[12px] font-medium uppercase tracking-[0.14em] text-foreground/40">
99 Director
100 </span>
101 <div className="h-px flex-1 bg-border/60" />
102 </div>
103 </div>
104
105 {/* Status section */}
106 <div className="border-t border-border/50 px-5 py-4">
107 <div className="flex items-center justify-center">
108 <span
109 className="text-[13px] font-medium text-foreground/70"
110 style={{ animation: "status-pulse 2.4s ease-in-out infinite" }}
111 >
112 Developing story...
113 </span>
114 </div>
115
116 {/* Progress bar */}
117 <div className="relative mt-3 h-[3px] overflow-hidden rounded-full bg-border/50">
118 <div
119 className="absolute inset-y-0 left-0 w-[28%] rounded-full bg-foreground/20"
120 style={{ animation: "bar-shimmer 2.2s ease-in-out infinite" }}
121 />
122 </div>
123 </div>
124 </div>
125
126 {/* Subtitle text */}
127 <p
128 className="mt-6 text-center text-[14px] leading-relaxed text-muted-foreground/60"
129 style={{ animation: "slate-fade-in 0.5s ease-out 0.2s both" }}
130 >
131 Describe your idea in the conversation. The script will appear here.
132 </p>
133 </div>
134 );
135 }
136
137 const wordCount = value.length;
138
139 return (
140 <div className="flex h-full flex-col">
141 {/* toolbar */}
142 <div className="flex items-center justify-between border-b border-border/40 px-4 py-2">
143 <span className="text-[11px] tabular-nums text-muted-foreground">
144 {wordCount} characters
145 </span>
146 <div className="flex items-center gap-2">
147 <button
148 type="button"
149 onClick={() => {
150 void onSave?.();
151 }}
152 disabled={saveDisabled ?? readOnly ?? saving}
153 className={cn(
154 "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",
155 "disabled:pointer-events-none disabled:opacity-40",
156 )}
157 >
158 <Save className="h-3 w-3" />
159 {saving ? "Saving..." : "Save"}
160 </button>
161 <button
162 type="button"
163 onClick={handleDownload}
164 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"
165 >
166 <Download className="h-3 w-3" />
167 Download
168 </button>
169 </div>
170 </div>
171
172 {/* editor */}
173 <div className="min-h-0 flex-1 overflow-y-auto px-4 py-3 scrollbar-thin">
174 <textarea
175 style={{ height: "100%", resize: "none" }}
176 ref={textareaRef}
177 value={value}
178 readOnly={readOnly}
179 onFocus={onFocus}
180 onBlur={onBlur}
181 onChange={(e) => {
182 if (readOnly) return;
183 onChange(e.target.value);
184 }}
185 placeholder="Edit the story script here..."
186 className={cn(
187 "w-full resize-none bg-transparent",
188 "text-[13.5px] leading-[1.9] text-foreground/88",
189 "placeholder:text-muted-foreground/35",
190 "focus:outline-none",
191 "min-h-[200px] pb-4",
192 )}
193 />
194 </div>
195
196 {/* bottom action bar */}
197 <div className="shrink-0 border-t border-border/50 px-4 py-3">
198 <Button
199 onClick={onApplyNext}
200 disabled={!hasContent || applyNextDisabled}
201 className={cn(
202 "w-full rounded-lg text-[13px] font-medium",
203 "bg-foreground text-background",
204 "hover:bg-foreground/90 active:scale-[0.99]",
205 "h-9 transition-all duration-200",
206 "disabled:opacity-40",
207 )}
208 >
209 {applyNextLabel}
210 <ArrowRight className="ml-1.5 h-3.5 w-3.5" />
211 </Button>
212 </div>
213 </div>
214 );
215 }
216
216 lines Plain Text