diff --git a/src/App.tsx b/src/App.tsx index 2a27379..dc192a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,7 +30,6 @@ import { Users, X } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx'; import { saveAs } from 'file-saver'; import { cn } from './lib/utils'; @@ -47,8 +46,10 @@ import { import { parseUploadedSourceFile } from './services/fileParsing'; import { getResumeStartIndex, getVersionsToGenerate, isConversionAbortedError } from './lib/conversionWorkflow'; import { AuthUser, loadCurrentDraft, login, logout, register, restoreSession, saveCurrentDraft } from './services/api'; -import { splitScriptPreviewBlocks } from './lib/scriptPreview'; +import { normalizeSceneHeadingMarkers, splitScriptPreviewBlocks } from './lib/scriptPreview'; import { parseSavedConversionEpisodeResults, sanitizeSavedConversionEpisodeResults } from './lib/conversionDraft'; +import { parseFinalizedOutline } from './lib/finalizedOutline'; +import { getDisplayedModeLabel } from './lib/modeSource'; type ScriptType = '\u77ed\u5267' | '\u7535\u89c6\u5267' | '\u7535\u5f71'; type ScriptFormat = '全部' | '场景' | '人物' | '台词' | '动作' | '镜头提示'; @@ -307,11 +308,20 @@ function parseCharacterProfileCards(text: string): CharacterPreviewCard[] { export default function App() { const [activeTab, setActiveTab] = useState<'conversion' | 'creation' | 'finalized'>('conversion'); const [previousTab, setPreviousTab] = useState<'conversion' | 'creation'>('conversion'); - const [finalizedScript, setFinalizedScript] = useState(() => localStorage.getItem('scriptflow_finalized_script') || ''); + const [finalizedScripts, setFinalizedScripts] = useState<{ conversion: string; creation: string }>(() => { + const legacy = localStorage.getItem('scriptflow_finalized_script') || ''; + return { + conversion: localStorage.getItem('scriptflow_finalized_script_conversion') || legacy, + creation: localStorage.getItem('scriptflow_finalized_script_creation') || legacy, + }; + }); + const finalizedScript = previousTab === 'creation' ? finalizedScripts.creation : finalizedScripts.conversion; useEffect(() => { + localStorage.setItem('scriptflow_finalized_script_conversion', finalizedScripts.conversion); + localStorage.setItem('scriptflow_finalized_script_creation', finalizedScripts.creation); localStorage.setItem('scriptflow_finalized_script', finalizedScript); - }, [finalizedScript]); + }, [finalizedScripts, finalizedScript]); // Common States (Three-level Filters) const [scriptType, setScriptType] = useState('\u77ed\u5267'); @@ -372,6 +382,7 @@ export default function App() { const conversionFileInputRef = useRef(null); const [isSyncing, setIsSyncing] = useState(false); const [isGeneratingAll, setIsGeneratingAll] = useState(false); + const [editingCreationScriptKey, setEditingCreationScriptKey] = useState(null); const [finalizedFilter, setFinalizedFilter] = useState('全部'); // Conversion Settings States @@ -462,6 +473,8 @@ export default function App() { conversionOutline, conversionCharacterProfiles, finalizedScript, + finalizedScriptConversion: finalizedScripts.conversion, + finalizedScriptCreation: finalizedScripts.creation, }, }); @@ -481,7 +494,11 @@ export default function App() { setConversionWorldview(typeof draft.conversionWorldview === 'string' ? draft.conversionWorldview : ''); setConversionOutline(typeof draft.conversionOutline === 'string' ? draft.conversionOutline : ''); setConversionCharacterProfiles(typeof draft.conversionCharacterProfiles === 'string' ? draft.conversionCharacterProfiles : ''); - setFinalizedScript(typeof draft.finalizedScript === 'string' ? draft.finalizedScript : ''); + const legacyFinalizedScript = typeof draft.finalizedScript === 'string' ? draft.finalizedScript : ''; + setFinalizedScripts({ + conversion: typeof draft.finalizedScriptConversion === 'string' ? draft.finalizedScriptConversion : legacyFinalizedScript, + creation: typeof draft.finalizedScriptCreation === 'string' ? draft.finalizedScriptCreation : legacyFinalizedScript, + }); setEditingConversionVersionKey(null); }; @@ -886,7 +903,7 @@ export default function App() { scriptToFinalize = finalizedPages.map((p) => { const script = p.scripts[p.selectedScriptIndex!]; const originalIndex = creationPages.indexOf(p); - return `## ?${originalIndex + 1}? + return `## 第${originalIndex + 1}集 ${script}`; }).join('\n\n---\n\n'); @@ -899,7 +916,7 @@ ${script}`; } setTimeout(() => { - setFinalizedScript(scriptToFinalize); + setFinalizedScripts((prev) => ({ ...prev, [activeTab as 'conversion' | 'creation']: scriptToFinalize })); setIsFinalizing(false); setIsEditingFinalized(false); setActiveTab('finalized'); @@ -978,6 +995,20 @@ ${script}`; setCreationPages(newPages); }; + const updateCreationScriptContent = (pageIndex: number, scriptIndex: number, content: string) => { + setCreationPages((prevPages) => prevPages.map((page, idx) => { + if (idx !== pageIndex) return page; + + const nextScripts = [...page.scripts]; + nextScripts[scriptIndex] = normalizeSceneHeadingMarkers(content); + + return { + ...page, + scripts: nextScripts + }; + })); + }; + const syncCurrentPage = async () => { const currentPage = creationPages[currentPageIndex]; if (!currentPage || !currentPage.story.trim()) return; @@ -989,6 +1020,7 @@ ${script}`; return; } + setEditingCreationScriptKey(null); setIsSyncing(true); // Initialize scripts with empty strings to show loading state @@ -1044,6 +1076,7 @@ ${script}`; return; } + setEditingCreationScriptKey(null); setIsGeneratingAll(true); const results = await generateAllScripts( creationPages, @@ -1062,7 +1095,7 @@ ${script}`; const exportAllScripts = () => { const fullScript = creationPages.map((p, i) => { const script = p.selectedScriptIndex !== null ? p.scripts[p.selectedScriptIndex] : (p.scripts[0] || '\u672a\u751f\u6210'); - return `## ?${i + 1} ? + return `## 第${i + 1}集 ${script}`; }).join('\n\n---\n\n'); @@ -1075,35 +1108,41 @@ ${script}`; URL.revokeObjectURL(url); }; - const sidebarItems = React.useMemo(() => { - const items: { type: 'episode' | 'scene', text: string, id: string }[] = []; - const lines = finalizedScript.split('\n'); - let sceneIdx = 0; - let episodeIdx = 0; - - lines.forEach((line) => { - const trimmed = line.trim(); - if (!trimmed) return; - - // Episode Detection - if (trimmed.startsWith('## ?') && trimmed.includes('?')) { - items.push({ - type: 'episode', - text: trimmed.replace(/^[#*]+/, '').trim(), - id: `episode-${episodeIdx++}` - }); - } - // Scene Detection - else if ((trimmed.startsWith('## 场景') || trimmed.startsWith('场景') || trimmed.startsWith('SCENE') || (trimmed.startsWith('#') && !trimmed.startsWith('##'))) && trimmed.length < 80) { - items.push({ - type: 'scene', - text: trimmed.replace(/^[#*]+/, '').trim(), - id: `scene-${sceneIdx++}` - }); - } + const finalizedOutline = React.useMemo(() => parseFinalizedOutline(finalizedScript), [finalizedScript]); + const sidebarItems = React.useMemo(() => finalizedOutline.sidebarItems, [finalizedOutline]); + const [expandedEpisodes, setExpandedEpisodes] = useState>(() => new Set()); + const [expandedScenes, setExpandedScenes] = useState>(() => new Set()); + + useEffect(() => { + const nextEpisodes = new Set(); + const nextScenes = new Set(); + + finalizedOutline.episodes.forEach((episode) => { + nextEpisodes.add(episode.id); + episode.scenes.slice(0, 2).forEach((scene) => nextScenes.add(scene.id)); }); - return items; - }, [finalizedScript]); + + setExpandedEpisodes(nextEpisodes); + setExpandedScenes(nextScenes); + }, [finalizedScript, finalizedOutline]); + + const toggleEpisodeCard = (episodeId: string) => { + setExpandedEpisodes((prev) => { + const next = new Set(prev); + if (next.has(episodeId)) next.delete(episodeId); + else next.add(episodeId); + return next; + }); + }; + + const toggleSceneCard = (sceneId: string) => { + setExpandedScenes((prev) => { + const next = new Set(prev); + if (next.has(sceneId)) next.delete(sceneId); + else next.add(sceneId); + return next; + }); + }; const activeCharacterProfiles = activeTab === 'conversion' ? conversionCharacterProfiles : creationCharacterProfiles; const isCharacterProfilesEditing = activeTab === 'conversion' ? isConversionCharacterProfilesEditing : isCreationCharacterProfilesEditing; @@ -1134,11 +1173,11 @@ ${script}`; const fullScript = finalizedPages.map((p) => { const script = p.scripts[p.selectedScriptIndex!]; const originalIndex = creationPages.indexOf(p); - return `## ?${originalIndex + 1} ? + return `## 第${originalIndex + 1}集 ${script}`; }).join('\n\n---\n\n'); - setFinalizedScript(fullScript); + setFinalizedScripts((prev) => ({ ...prev, creation: fullScript })); setActiveTab('finalized'); }; @@ -1236,7 +1275,7 @@ ${script}`; {/* Header */}
-

ScriptFlow {activeTab === 'conversion' ? 'AI 剧本转换工具' : '即时创作模式'}

+

ScriptFlow {getDisplayedModeLabel(activeTab, previousTab)}

@@ -1524,7 +1563,7 @@ ${script}`; }} value={finalizedScript} onChange={(e) => { - setFinalizedScript(e.target.value); + setFinalizedScripts((prev) => ({ ...prev, [previousTab]: e.target.value })); e.target.style.height = 'auto'; e.target.style.height = e.target.scrollHeight + 'px'; }} @@ -1534,103 +1573,80 @@ ${script}`; />
) : ( -
- {/* Page Content Container */} -
- {/* Script Content */} -
- { - try { - const childrenArray = React.Children.toArray(children); - const text = childrenArray.map(c => c?.toString() || '').join(''); - const trimmedText = text.trim(); - - // 1. Scene Detection - const isScene = (trimmedText.includes('SCENE') || trimmedText.includes('场景') || trimmedText.startsWith('#')) && trimmedText.length < 80; - - if (isScene) { - // We need to find the index of this scene in the global list to match the sidebar ID - // This is a bit expensive but necessary for navigation - const allScenes = finalizedScript.split('\n') - .filter(line => { - const t = line.trim(); - return (t.startsWith('## 场景') || t.startsWith('场景') || t.startsWith('SCENE') || (t.startsWith('#') && !t.startsWith('##'))) && t.length < 80; - }); - const sceneIdx = allScenes.findIndex(s => s.includes(trimmedText) || trimmedText.includes(s.replace(/^[#*]+/, '').trim())); - - const sceneTitle = trimmedText.replace(/^[#*]+/, '').trim(); - return ( -
= 0 ? sceneIdx : 'unknown'}`} className="mt-10 mb-6 py-2 border-b-2 border-black/5 font-bold uppercase tracking-wide text-lg"> - {sceneTitle} -
- ); - } - return

{children}

; - } catch (e) { - return

{children}

; - } - }, - h1: ({children}) =>

{children}

, - h2: ({children}) => { - const text = React.Children.toArray(children).map(c => c?.toString() || '').join(''); - let id = ''; - if (text.startsWith('?') && text.includes('?')) { - const allEpisodes = finalizedScript.split('\n').filter(l => l.trim().startsWith('## ?') && l.trim().includes('?')); - const idx = allEpisodes.findIndex(e => e.includes(text)); - id = `episode-${idx}`; - } else if (text.startsWith('场景') || text.includes('SCENE')) { - const allScenes = finalizedScript.split('\n') - .filter(line => { - const t = line.trim(); - return (t.startsWith('## 场景') || t.startsWith('场景') || t.startsWith('SCENE') || (t.startsWith('#') && !t.startsWith('##'))) && t.length < 80; - }); - const idx = allScenes.findIndex(s => s.includes(text)); - id = `scene-${idx}`; - } - return

{children}

; - }, - h3: ({children}) =>

{children}

- }} - > - {finalizedFilter === '全部' - ? finalizedScript - : finalizedScript.split('\n').filter(line => { - const trimmedLine = line.trim(); - if (!trimmedLine) return false; - - // 1. Scene - const isScene = trimmedLine.includes('SCENE') || trimmedLine.includes('场景') || trimmedLine.startsWith('#'); - - // 2. Character - // Rule: Contains colon, len <= 20, not a scene - const isChar = !isScene && (trimmedLine.includes('?') || trimmedLine.includes(':')) && trimmedLine.length <= 20; - - // 3. Dialogue - // Rule: Starts with quote - const isDialogue = !isScene && (trimmedLine.startsWith('?') || trimmedLine.startsWith('\"')); - - // 4. Camera - // Rule: Keywords - const cameraKeywords = ['镜头', '特写', '远景', '近景', '中景', '全景', '推镜', '拉镜', '切换']; - const isCamera = !isScene && cameraKeywords.some(k => trimmedLine.includes(k)); - - // 5. Action - // Rule: None of the above - const isAction = !isScene && !isChar && !isDialogue && !isCamera; +
+ {finalizedOutline.episodes.map((episode) => { + const episodeExpanded = expandedEpisodes.has(episode.id); - if (finalizedFilter === '场景' && !isScene) return false; - if (finalizedFilter === '人物' && !isChar) return false; - if (finalizedFilter === '台词' && !isDialogue && !isChar) return false; // Keep character names with dialogue for context - if (finalizedFilter === '动作' && !isAction) return false; - if (finalizedFilter === '镜头提示' && !isCamera) return false; - return true; - }).join('\n\n') - } - -
-
+ return ( +
+ + + {episodeExpanded && ( +
+ {episode.scenes.map((scene, sceneIndex) => { + const sceneExpanded = expandedScenes.has(scene.id); + const sceneWordCount = scene.content.replace(/\s+/g, '').length; + + return ( +
+ + + {sceneExpanded && ( +
+
+
+                                            {scene.content || '\uff08\u672c\u573a\u666f\u6682\u65e0\u5185\u5bb9\uff09'}
+                                          
+
+
+ )} +
+ ); + })} +
+ )} +
+ ); + })}
)}
@@ -2395,52 +2411,99 @@ ${script}`;
{(isSyncing || creationPages[currentPageIndex]?.scripts?.some(s => s)) ? ( -
- {creationPages[currentPageIndex]?.scripts?.map((script, idx) => ( -
-
-
- {['A', 'B', 'C'][idx]}版本 - {isSyncing && !script && } -
-
- - -
-
-
- {script ? ( - {script} - ) : isSyncing ? ( -
-
-
-
+
+ {creationPages[currentPageIndex]?.scripts?.map((script, idx) => { + const creationScriptKey = (creationPages[currentPageIndex]?.id || '') + '-' + idx; + const isEditingCreationScript = editingCreationScriptKey === creationScriptKey; + + return ( +
+
+
+ {['A', 'B', 'C'][idx] + '\u7248\u672c'} + {isSyncing && !script && }
- ) : null} +
+ + + +
+
+ +
+ {isEditingCreationScript && !isSyncing ? ( +
+ + {'\u573a\u666f\u6807\u8bc6\uff1a##\uff08\u4e0d\u53ef\u5220\u9664\uff09'} + +