返回 oh-my-ppt
SpeechScriptDrawer.tsx
根目录 / src / renderer / src / components / session-detail / speech / SpeechScriptDrawer.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2 import { Check, Copy, FileText, Loader2, X } from 'lucide-react'
3 import { cn } from '@renderer/lib/utils'
4 import { Button } from '../../ui/Button'
5 import { ModelSplitButton } from '../../model/ModelActionButton'
6 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/Select'
7 import { useT } from '@renderer/i18n'
8 import { ipc } from '@renderer/lib/ipc'
9 import type { SpeechConfig, SpeechLength, SpeechScope, SpeechStyle } from '@shared/speech'
10 import { useSpeechScriptDrawerController } from './useSpeechScriptDrawerController'
11 import { sessionDetailRightPanelContentClass } from '../workspace/right-panel/styles'
12
13 export type { SpeechConfig }
14
15 export function SpeechScriptDrawer({ sessionId }: { sessionId: string }): React.JSX.Element | null {
16 const t = useT()
17 const {
18 open,
19 isGenerating,
20 speechProgress,
21 speechConfig,
22 modelAction,
23 currentPageNumber,
24 currentPageTitle,
25 setSpeechConfig,
26 generate,
27 close
28 } = useSpeechScriptDrawerController(sessionId)
29 const [script, setScript] = useState<string | null>(null)
30 const [copied, setCopied] = useState(false)
31
32 const loadScript = useCallback((): void => {
33 void ipc
34 .getSpeechScript(sessionId)
35 .then((result) => {
36 setScript(result.script ?? null)
37 })
38 .catch(() => {
39 setScript(null)
40 })
41 }, [sessionId])
42
43 const visibleScript = useMemo(() => {
44 if (!script) return null
45 if (speechConfig.scope === 'all') return script
46
47 const sections = script
48 .split(/\n\s*---\s*\n/g)
49 .map((section) => section.trim())
50 .filter(Boolean)
51 const title = (currentPageTitle || '').replace(/\s+/g, ' ').trim().toLowerCase()
52 const pageNumber = Number.isFinite(currentPageNumber) ? Number(currentPageNumber) : null
53
54 return (
55 sections.find((section) => {
56 const heading = (section.split(/\r?\n/).find((line) => line.trim().length > 0) || '')
57 .replace(/^#+\s*/, '')
58 .replace(/\s+/g, ' ')
59 .trim()
60 .toLowerCase()
61 if (
62 pageNumber &&
63 new RegExp(`(?:第\\s*${pageNumber}\\s*页|slide\\s*${pageNumber}\\b)`, 'i').test(heading)
64 ) {
65 return true
66 }
67 return Boolean(title) && heading.includes(title)
68 }) || null
69 )
70 }, [currentPageNumber, currentPageTitle, script, speechConfig.scope])
71
72 // Load on mount / session change
73 useEffect(() => {
74 loadScript()
75 }, [loadScript])
76
77 // Reload after generation finishes (skip initial render via ref)
78 const isFirstRender = useRef(true)
79 useEffect(() => {
80 if (isFirstRender.current) {
81 isFirstRender.current = false
82 return
83 }
84 if (!isGenerating) {
85 loadScript()
86 }
87 }, [isGenerating, loadScript])
88
89 const handleScopeChange = (scope: SpeechScope): void => {
90 setSpeechConfig({ ...speechConfig, scope })
91 }
92
93 const generationLabel =
94 speechConfig.scope === 'single' && currentPageNumber
95 ? t('sessionDetail.speechScriptGeneratingSingle', { pageNumber: currentPageNumber })
96 : speechProgress
97 ? t('sessionDetail.speechScriptGenerating', {
98 current: speechProgress.current,
99 total: speechProgress.total
100 })
101 : t('sessionDetail.speechScriptGeneratingInit')
102
103 useEffect(() => {
104 setCopied(false)
105 }, [visibleScript])
106
107 const handleCopy = async (): Promise<void> => {
108 if (!visibleScript) return
109 try {
110 await navigator.clipboard.writeText(visibleScript)
111 setCopied(true)
112 setTimeout(() => setCopied(false), 2000)
113 } catch {
114 const textarea = document.createElement('textarea')
115 textarea.value = visibleScript
116 textarea.style.position = 'fixed'
117 textarea.style.opacity = '0'
118 document.body.appendChild(textarea)
119 textarea.select()
120 try {
121 document.execCommand('copy')
122 setCopied(true)
123 setTimeout(() => setCopied(false), 2000)
124 } finally {
125 document.body.removeChild(textarea)
126 }
127 }
128 }
129
130 const handleViewFile = (): void => {
131 void ipc.openSpeechScriptFile(sessionId)
132 }
133
134 const handleGenerate = (modelConfigId: string): void => {
135 const nextConfig = { ...speechConfig, modelConfigId }
136 setSpeechConfig(nextConfig)
137 generate(nextConfig)
138 }
139
140 if (!open || !sessionId) return null
141
142 return (
143 <div className={sessionDetailRightPanelContentClass}>
144 {/* Header card */}
145 <div className="relative mx-2 mt-2 overflow-hidden rounded-[0.85rem] border border-[#e1d6c4]/58 bg-[#fffaf1]/68 px-2.5 py-2 shadow-[0_2px_8px_rgba(77,61,43,0.05)]">
146 <div className="pointer-events-none absolute -right-8 -top-10 h-20 w-20 rounded-[30%_70%_70%_30%/30%_30%_70%_70%] bg-[#c7d9b4]/10" />
147 <div className="relative flex items-center justify-between">
148 <h3 className="text-[12px] font-semibold tracking-[0.03em] text-[#34402c]">
149 {t('sessionDetail.speechScriptDialogTitle')}
150 </h3>
151 <button
152 type="button"
153 aria-label={t('sessionDetail.closeSpeechDrawer')}
154 onClick={close}
155 className="rounded-md p-0.5 text-[#9a8f80] transition-colors hover:bg-[#ebe4d6]/80 hover:text-[#3e4a32]"
156 >
157 <X className="h-3 w-3" />
158 </button>
159 </div>
160
161 {/* Scope tabs */}
162 <div className="mt-2 flex gap-0.5 rounded-lg bg-[#ede5d6]/52 p-0.5">
163 {(['all', 'single'] as SpeechScope[]).map((s) => (
164 <button
165 key={s}
166 type="button"
167 onClick={() => handleScopeChange(s)}
168 className={cn(
169 'flex-1 rounded-[0.55rem] py-1 text-[11px] font-medium transition-all',
170 speechConfig.scope === s
171 ? 'bg-[#fffaf1] text-[#3e4a32] shadow-[0_1px_3px_rgba(74,59,42,0.08)]'
172 : 'text-[#9a8f80] hover:text-[#5a6b4a]'
173 )}
174 >
175 {s === 'all'
176 ? t('sessionDetail.speechScriptScopeAll')
177 : t('sessionDetail.speechScriptScopeSingle')}
178 </button>
179 ))}
180 </div>
181 </div>
182
183 {/* Scope description */}
184 <p className="shrink-0 px-2.5 pt-2 text-[10px] text-[#9a8f80]">
185 {speechConfig.scope === 'all'
186 ? t('sessionDetail.speechScriptScopeAllDesc')
187 : currentPageTitle || t('sessionDetail.speechScriptScopeSingleDesc')}
188 </p>
189
190 {/* Config card (fixed, no scroll) */}
191 <div className="mx-2 mt-1.5 shrink-0 overflow-hidden rounded-[0.8rem] border border-[#e1d6c4]/58 bg-[#fffaf1]/68 shadow-[0_2px_8px_rgba(77,61,43,0.05)]">
192 {/* Style row */}
193 <div className="flex items-center gap-2 border-b border-[#ede5d6]/50 px-2.5 py-1.5">
194 <span className="shrink-0 text-[10px] font-semibold tracking-[0.05em] text-[#7a875f]">
195 {t('sessionDetail.speechScriptStyle')}
196 </span>
197 <Select
198 value={speechConfig.style}
199 onValueChange={(v) => setSpeechConfig({ ...speechConfig, style: v as SpeechStyle })}
200 >
201 <SelectTrigger className="h-7 flex-1 border-[#d8ccb5]/60 bg-[#fffdf8]/60 text-[11px]">
202 <SelectValue />
203 </SelectTrigger>
204 <SelectContent>
205 <SelectItem value="conversational">
206 {t('sessionDetail.speechScriptStyleConversational')}
207 </SelectItem>
208 <SelectItem value="formal">{t('sessionDetail.speechScriptStyleFormal')}</SelectItem>
209 <SelectItem value="storytelling">
210 {t('sessionDetail.speechScriptStyleStorytelling')}
211 </SelectItem>
212 <SelectItem value="custom">{t('sessionDetail.speechScriptStyleCustom')}</SelectItem>
213 </SelectContent>
214 </Select>
215 </div>
216
217 {/* Custom style textarea */}
218 {speechConfig.style === 'custom' && (
219 <div className="border-b border-[#ede5d6]/50 px-2.5 py-1.5">
220 <textarea
221 className="w-full resize-none rounded-lg border border-[#d8ccb5]/80 bg-[#fffdf8]/88 px-2.5 py-1.5 text-[11px] text-[#3f4b35] placeholder:text-[#b0a898] focus:border-[#9bb98a] focus:outline-none"
222 rows={2}
223 placeholder={t('sessionDetail.speechScriptStyleCustomPlaceholder')}
224 value={speechConfig.customStyle ?? ''}
225 onChange={(e) => setSpeechConfig({ ...speechConfig, customStyle: e.target.value })}
226 />
227 </div>
228 )}
229
230 {/* Length row */}
231 <div className="flex items-center gap-2 px-2.5 py-1.5">
232 <span className="shrink-0 text-[10px] font-semibold tracking-[0.05em] text-[#7a875f]">
233 {t('sessionDetail.speechScriptLength')}
234 </span>
235 <Select
236 value={speechConfig.length}
237 onValueChange={(v) => setSpeechConfig({ ...speechConfig, length: v as SpeechLength })}
238 >
239 <SelectTrigger className="h-7 flex-1 border-[#d8ccb5]/60 bg-[#fffdf8]/60 text-[11px]">
240 <SelectValue />
241 </SelectTrigger>
242 <SelectContent>
243 <SelectItem value="short">{t('sessionDetail.speechScriptLengthShort')}</SelectItem>
244 <SelectItem value="medium">{t('sessionDetail.speechScriptLengthMedium')}</SelectItem>
245 <SelectItem value="long">{t('sessionDetail.speechScriptLengthLong')}</SelectItem>
246 </SelectContent>
247 </Select>
248 </div>
249
250 {/* Generate button */}
251 <div className="border-t border-[#ede5d6]/60 px-3 py-2.5">
252 <ModelSplitButton
253 modelAction={modelAction}
254 label={t(
255 visibleScript
256 ? 'sessionDetail.speechScriptRegenerate'
257 : 'sessionDetail.speechScriptGenerate'
258 )}
259 loadingLabel={generationLabel}
260 loading={isGenerating}
261 disabled={isGenerating}
262 icon={FileText}
263 tone="primary"
264 size="sm"
265 className="w-full rounded-xl"
266 mainClassName="min-w-0 flex-1 justify-center text-xs"
267 triggerClassName="h-8"
268 onRun={handleGenerate}
269 />
270 </div>
271 </div>
272
273 {/* Divider */}
274 <div className="mx-3 my-2 shrink-0 border-t border-[#e1d6c4]/50" />
275
276 {/* Result area (only this section scrolls) */}
277 {isGenerating ? (
278 <div className="flex flex-col items-center gap-2 py-8">
279 <Loader2 className="h-5 w-5 animate-spin text-[#6f8159]" />
280 <p className="text-center text-xs text-[#7a6b56]">{generationLabel}</p>
281 </div>
282 ) : visibleScript ? (
283 <div className="flex min-h-0 flex-1 flex-col gap-2 px-2.5 pb-3">
284 <div className="min-h-0 flex-1 overflow-y-auto rounded-[1.15rem] border border-[#e1d6c4]/72 bg-[#fffaf1]/78 px-3 py-3 shadow-[0_4px_12px_rgba(77,61,43,0.06)]">
285 <pre className="whitespace-pre-wrap font-sans text-[12px] leading-relaxed text-[#3f4b35]">
286 {visibleScript}
287 </pre>
288 </div>
289 <div className="flex shrink-0 gap-2">
290 <Button
291 variant="outline"
292 size="sm"
293 className="flex-1 gap-1.5 text-xs"
294 onClick={() => void handleCopy()}
295 >
296 {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
297 {copied ? t('sessionDetail.speechScriptCopied') : t('sessionDetail.speechScriptCopy')}
298 </Button>
299 <Button
300 variant="outline"
301 size="sm"
302 className="flex-1 gap-1.5 text-xs"
303 onClick={handleViewFile}
304 >
305 <FileText className="h-3.5 w-3.5" />
306 {t('sessionDetail.speechScriptViewFile')}
307 </Button>
308 </div>
309 </div>
310 ) : (
311 <p className="py-6 text-center text-[11px] text-[#b0a898]">
312 {t('sessionDetail.speechScriptEmptyHint')}
313 </p>
314 )}
315 </div>
316 )
317 }
318
318 lines Plain Text