| 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 |