返回 oh-my-ppt
MasterWorkbenchPanel.tsx
根目录 / src / renderer / src / components / session-detail / workspace / workbench / MasterWorkbenchPanel.tsx
1 import { useEffect, useRef, useState } from 'react'
2 import { ChevronDown, LayoutTemplate, Layers3, Loader2, Palette, X } from 'lucide-react'
3 import { useT } from '@renderer/i18n'
4 import { ipc, type FontListItem } from '@renderer/lib/ipc'
5 import {
6 useEditSessionStore,
7 useGenerateStore,
8 useLayoutMasterStore,
9 useMasterWorkbenchStore,
10 useSessionDetailRuntimeStore,
11 useSessionDetailUiStore,
12 useSessionStore,
13 useToastStore
14 } from '@renderer/store'
15 import {
16 buildDefaultMasterConfig,
17 buildDefaultMasterElementsConfig,
18 normalizeMasterConfig,
19 type SessionMasterConfig,
20 type SessionMasterStatus
21 } from '@shared/master'
22 import { Button } from '../../../ui/Button'
23 import {
24 Dialog,
25 DialogContent,
26 DialogDescription,
27 DialogFooter,
28 DialogHeader,
29 DialogTitle
30 } from '../../../ui/Dialog'
31 import { Checkbox } from '../../../ui/Checkbox'
32 import { Input } from '../../../ui/Input'
33 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../ui/Select'
34 import { MasterGradientEditor } from '../../../gradient-editor/MasterGradientEditor'
35 import { MasterElementsEditor } from '../../../master-elements/MasterElementsEditor'
36 import { MasterLayoutLibraryDialog } from '../../../master-layouts/MasterLayoutLibraryDialog'
37 import {
38 DropdownMenu,
39 DropdownMenuContent,
40 DropdownMenuItem,
41 DropdownMenuTrigger
42 } from '../../../ui/DropdownMenu'
43
44 const fontPresetKeys = ['inherit', 'sans', 'serif', 'mono'] as const
45
46 const fontPresetLabelKeys = {
47 inherit: 'sessionDetail.masterFontInherit',
48 sans: 'sessionDetail.masterFontSans',
49 serif: 'sessionDetail.masterFontSerif',
50 mono: 'sessionDetail.masterFontMono'
51 } as const
52
53 const getRecord = (value: unknown): Record<string, unknown> =>
54 value && typeof value === 'object' && !Array.isArray(value)
55 ? (value as Record<string, unknown>)
56 : {}
57
58 const getJsonRecord = (value: unknown): Record<string, unknown> => {
59 if (typeof value !== 'string' || !value.trim()) return {}
60 try {
61 return getRecord(JSON.parse(value))
62 } catch {
63 return {}
64 }
65 }
66
67 const getFontFamily = (value: unknown): string | null => {
68 const family = typeof value === 'string' ? value.trim() : ''
69 return family || null
70 }
71
72 export function MasterWorkbenchPanel(): React.JSX.Element | null {
73 const t = useT()
74 const [styleOpen, setStyleOpen] = useState(false)
75 const [elementsOpen, setElementsOpen] = useState(false)
76 const [loading, setLoading] = useState(false)
77 const [saving, setSaving] = useState(false)
78 const [error, setError] = useState('')
79 const [status, setStatus] = useState<SessionMasterStatus | null>(null)
80 const [fontOptions, setFontOptions] = useState<FontListItem[]>([])
81 const masterLoadRequestRef = useRef(0)
82 const config = useMasterWorkbenchStore((state) => state.config)
83 const setConfig = useMasterWorkbenchStore((state) => state.setConfig)
84 const updateConfig = useMasterWorkbenchStore((state) => state.updateConfig)
85 const setLayoutLibraryOpen = useLayoutMasterStore((state) => state.setOpen)
86 const isSavingEdits = useEditSessionStore((state) => state.isSavingEdits)
87 const isApplyingSyncElement = useEditSessionStore((state) => state.isApplyingSyncElement)
88 const currentSession = useSessionStore((state) => state.currentSession)
89 const sessionId = currentSession?.id || ''
90 const currentSessionIdRef = useRef(sessionId)
91 currentSessionIdRef.current = sessionId
92 const mutationBusy = useGenerateStore((state) =>
93 Boolean(
94 state.isGenerating ||
95 state.pageEditJobs[sessionId] ||
96 state.deckEditJobs[sessionId] ||
97 state.styleSwitchJobs[sessionId]
98 )
99 )
100 const currentPages = useSessionStore((state) => state.currentGeneratedPages)
101 const selectedPageId = useSessionDetailUiStore((state) => state.selectedPageId)
102 const bumpThumbnailVersion = useSessionDetailUiStore((state) => state.bumpThumbnailVersion)
103 const reloadCurrentPreviewIgnoringCache = useSessionDetailRuntimeStore(
104 (state) => state.reloadCurrentPreviewIgnoringCache
105 )
106 const toastError = useToastStore((state) => state.error)
107 const toastSuccess = useToastStore((state) => state.success)
108 const busy = saving || isSavingEdits || isApplyingSyncElement || mutationBusy
109 const open = styleOpen || elementsOpen
110
111 const refreshPreview = (): void => {
112 reloadCurrentPreviewIgnoringCache()
113 currentPages.forEach((page) => {
114 if (page.pageId) bumpThumbnailVersion(page.pageId)
115 })
116 }
117
118 const loadMaster = async (requestId: number, requestedSessionId: string): Promise<void> => {
119 const isCurrentRequest = (): boolean =>
120 masterLoadRequestRef.current === requestId &&
121 currentSessionIdRef.current === requestedSessionId
122 if (!isCurrentRequest()) return
123 setLoading(true)
124 setError('')
125 setStatus(null)
126 try {
127 const [next, fonts] = await Promise.all([
128 ipc.getSessionMaster({ sessionId: requestedSessionId }),
129 ipc.listFonts()
130 ])
131 if (!isCurrentRequest()) return
132 setStatus(next)
133 setConfig(normalizeMasterConfig(next.config))
134 setFontOptions([...fonts.userFonts, ...fonts.googleFonts])
135 } catch (loadError) {
136 if (!isCurrentRequest()) return
137 const message =
138 loadError instanceof Error ? loadError.message : t('sessionDetail.masterLoadFailed')
139 setError(message)
140 toastError(message)
141 } finally {
142 if (isCurrentRequest()) setLoading(false)
143 }
144 }
145
146 useEffect(() => {
147 const requestId = ++masterLoadRequestRef.current
148 if (!open || !sessionId) return
149 void loadMaster(requestId, sessionId)
150 return () => {
151 if (masterLoadRequestRef.current === requestId) masterLoadRequestRef.current += 1
152 }
153 }, [open, sessionId])
154
155 if (!sessionId) return null
156
157 const designContract = getJsonRecord(currentSession?.designContract)
158 const fontSelection = getRecord(getJsonRecord(currentSession?.metadata).fontSelection)
159 const inheritedFonts = {
160 title:
161 getFontFamily(designContract.titleFont) ||
162 getFontFamily(getRecord(fontSelection.title).family),
163 body:
164 getFontFamily(designContract.bodyFont) || getFontFamily(getRecord(fontSelection.body).family)
165 }
166 const getInheritLabel = (family: string | null): string =>
167 family
168 ? t('sessionDetail.masterFontInheritWithFamily', { family })
169 : t('sessionDetail.masterFontInherit')
170 const resolveFontValue = (
171 family: string | null,
172 preset: SessionMasterConfig['titleFontPreset']
173 ): string => (family ? `font:${family}` : `preset:${preset}`)
174 const updateFont = (role: 'title' | 'body', value: string): void => {
175 const family = value.startsWith('font:') ? value.slice('font:'.length) : null
176 const preset = value.startsWith('preset:')
177 ? (value.slice('preset:'.length) as SessionMasterConfig['titleFontPreset'])
178 : 'inherit'
179 updateConfig(
180 role === 'title'
181 ? { titleFontFamily: family, titleFontPreset: preset }
182 : { bodyFontFamily: family, bodyFontPreset: preset }
183 )
184 }
185
186 const updateFontSize = (role: 'title' | 'body', value: string): void => {
187 const raw = value.trim()
188 const size = raw === '' ? null : Number(raw)
189 if (size !== null && (!Number.isInteger(size) || size < 1)) return
190 updateConfig(role === 'title' ? { titleFontSize: size } : { bodyFontSize: size })
191 }
192
193 const saveMaster = async (): Promise<void> => {
194 if (busy) return
195 setSaving(true)
196 setError('')
197 try {
198 const next = await ipc.saveSessionMaster({ sessionId, config })
199 setStatus(next)
200 setConfig(normalizeMasterConfig(next.config))
201 refreshPreview()
202 toastSuccess(t('sessionDetail.masterSaved'))
203 setStyleOpen(false)
204 setElementsOpen(false)
205 } catch (saveError) {
206 const message =
207 saveError instanceof Error ? saveError.message : t('sessionDetail.masterSaveFailed')
208 setError(message)
209 toastError(message)
210 } finally {
211 setSaving(false)
212 }
213 }
214
215 const toggleCurrentPageElements = async (disabled: boolean): Promise<void> => {
216 if (!selectedPageId || busy) return
217 setSaving(true)
218 setError('')
219 try {
220 await ipc.setSessionMasterPageOverride({ sessionId, pageId: selectedPageId, disabled })
221 const next = await ipc.getSessionMaster({ sessionId })
222 setStatus(next)
223 refreshPreview()
224 } catch (overrideError) {
225 const message =
226 overrideError instanceof Error
227 ? overrideError.message
228 : t('sessionDetail.masterPageOverrideFailed')
229 setError(message)
230 toastError(message)
231 } finally {
232 setSaving(false)
233 }
234 }
235
236 const currentPageElementsDisabled = Boolean(
237 selectedPageId && status?.disabledPageIds.includes(selectedPageId)
238 )
239
240 const closeElementsDialog = (): void => {
241 if (saving) return
242 if (status) setConfig(normalizeMasterConfig(status.config))
243 setError('')
244 setElementsOpen(false)
245 }
246
247 const closeStyleDialog = (): void => {
248 if (saving) return
249 if (status) setConfig(normalizeMasterConfig(status.config))
250 setError('')
251 setStyleOpen(false)
252 }
253
254 return (
255 <>
256 <DropdownMenu>
257 <DropdownMenuTrigger asChild>
258 <Button
259 type="button"
260 variant="outline"
261 size="sm"
262 className="h-6 min-w-[56px] shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-[10px] font-bold text-[#4f5f40] shadow-none hover:bg-[#fffaf1]/54 hover:text-[#314028]"
263 disabled={busy}
264 >
265 <Palette className="h-3 w-3" />
266 {t('sessionDetail.master')}
267 <ChevronDown className="h-3 w-3" />
268 </Button>
269 </DropdownMenuTrigger>
270 <DropdownMenuContent align="end" className="w-40">
271 <DropdownMenuItem onSelect={() => setStyleOpen(true)}>
272 <Palette className="h-3.5 w-3.5 text-[#637552]" />
273 {t('sessionDetail.masterStyle')}
274 </DropdownMenuItem>
275 <DropdownMenuItem onSelect={() => setElementsOpen(true)}>
276 <Layers3 className="h-3.5 w-3.5 text-[#637552]" />
277 {t('sessionDetail.masterGlobalElements')}
278 </DropdownMenuItem>
279 <DropdownMenuItem onSelect={() => setLayoutLibraryOpen(true)}>
280 <LayoutTemplate className="h-3.5 w-3.5 text-[#637552]" />
281 {t('sessionDetail.masterLayoutLibrary')}
282 </DropdownMenuItem>
283 </DropdownMenuContent>
284 </DropdownMenu>
285
286 <MasterLayoutLibraryDialog />
287
288 <Dialog
289 open={styleOpen}
290 onOpenChange={(nextOpen) => {
291 if (!nextOpen) closeStyleDialog()
292 else if (!saving) setStyleOpen(true)
293 }}
294 >
295 <DialogContent showClose={!saving} className="!max-w-[600px] gap-5 p-6">
296 <DialogHeader>
297 <DialogTitle>{t('sessionDetail.masterStyleTitle')}</DialogTitle>
298 <DialogDescription>{t('sessionDetail.masterDescription')}</DialogDescription>
299 </DialogHeader>
300
301 {loading ? (
302 <div className="flex justify-center py-10 text-[#667257]">
303 <Loader2 className="h-5 w-5 animate-spin" />
304 </div>
305 ) : (
306 <fieldset disabled={busy} className="space-y-5">
307 <div className="space-y-3">
308 <label className="flex cursor-pointer items-center justify-between gap-4 text-sm text-[#4a563d]">
309 <span>{t('sessionDetail.masterOverrideBackground')}</span>
310 <Checkbox
311 checked={config.backgroundMode === 'override'}
312 onCheckedChange={(checked) =>
313 updateConfig({ backgroundMode: checked === true ? 'override' : 'inherit' })
314 }
315 />
316 </label>
317 <MasterGradientEditor />
318 </div>
319
320 <div className="grid gap-x-8 gap-y-6 border-t border-[#e6ddcf] pt-5 sm:grid-cols-2">
321 <div className="space-y-3 text-sm text-[#4a563d]">
322 <span>{t('sessionDetail.masterTitleFont')}</span>
323 <Select
324 value={resolveFontValue(config.titleFontFamily, config.titleFontPreset)}
325 onValueChange={(value) => updateFont('title', value)}
326 >
327 <SelectTrigger className="h-8">
328 <SelectValue />
329 </SelectTrigger>
330 <SelectContent>
331 {fontPresetKeys.map((preset) => (
332 <SelectItem key={preset} value={`preset:${preset}`}>
333 {preset === 'inherit'
334 ? getInheritLabel(inheritedFonts.title)
335 : t(fontPresetLabelKeys[preset])}
336 </SelectItem>
337 ))}
338 {fontOptions.map((font) => (
339 <SelectItem key={`${font.source}:${font.id}`} value={`font:${font.family}`}>
340 {font.family}
341 </SelectItem>
342 ))}
343 </SelectContent>
344 </Select>
345 <label className="flex items-center gap-4 text-sm text-[#667257]">
346 <span>{t('sessionDetail.masterTitleFontSize')}</span>
347 <span className="relative block w-[128px]">
348 <Input
349 type="number"
350 min={12}
351 max={160}
352 value={config.titleFontSize ?? ''}
353 placeholder={t('sessionDetail.masterFontSizeInherit')}
354 aria-label={t('sessionDetail.masterTitleFontSize')}
355 className="h-8 pr-7 text-center text-xs"
356 onChange={(event) => updateFontSize('title', event.target.value)}
357 />
358 <span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-[#89917d]">
359 px
360 </span>
361 </span>
362 </label>
363 </div>
364
365 <div className="space-y-3 text-sm text-[#4a563d]">
366 <span>{t('sessionDetail.masterBodyFont')}</span>
367 <Select
368 value={resolveFontValue(config.bodyFontFamily, config.bodyFontPreset)}
369 onValueChange={(value) => updateFont('body', value)}
370 >
371 <SelectTrigger className="h-8">
372 <SelectValue />
373 </SelectTrigger>
374 <SelectContent>
375 {fontPresetKeys.map((preset) => (
376 <SelectItem key={preset} value={`preset:${preset}`}>
377 {preset === 'inherit'
378 ? getInheritLabel(inheritedFonts.body)
379 : t(fontPresetLabelKeys[preset])}
380 </SelectItem>
381 ))}
382 {fontOptions.map((font) => (
383 <SelectItem key={`${font.source}:${font.id}`} value={`font:${font.family}`}>
384 {font.family}
385 </SelectItem>
386 ))}
387 </SelectContent>
388 </Select>
389 <label className="flex items-center gap-4 text-sm text-[#667257]">
390 <span>{t('sessionDetail.masterBodyFontSize')}</span>
391 <span className="relative block w-[128px]">
392 <Input
393 type="number"
394 min={8}
395 max={96}
396 value={config.bodyFontSize ?? ''}
397 placeholder={t('sessionDetail.masterFontSizeInherit')}
398 aria-label={t('sessionDetail.masterBodyFontSize')}
399 className="h-8 pr-7 text-center text-xs"
400 onChange={(event) => updateFontSize('body', event.target.value)}
401 />
402 <span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-[#89917d]">
403 px
404 </span>
405 </span>
406 </label>
407 </div>
408 </div>
409
410 {status && status.unlinkedPageCount > 0 && (
411 <p className="text-xs leading-4 text-[#667257]">
412 {t('sessionDetail.masterUnlinkedHint', { count: status.unlinkedPageCount })}
413 </p>
414 )}
415 {error && <p className="text-xs leading-4 text-[#a14f4a]">{error}</p>}
416 </fieldset>
417 )}
418
419 <DialogFooter>
420 <Button
421 type="button"
422 variant="outline"
423 size="sm"
424 disabled={busy || loading || (status?.missingPageCount || 0) > 0}
425 onClick={() => setConfig(buildDefaultMasterConfig())}
426 >
427 {t('sessionDetail.masterReset')}
428 </Button>
429 <Button
430 type="button"
431 size="sm"
432 disabled={busy || loading}
433 onClick={() => void saveMaster()}
434 >
435 {saving ? t('common.saving') : t('sessionDetail.masterSaveAndApply')}
436 </Button>
437 </DialogFooter>
438 </DialogContent>
439 </Dialog>
440
441 <Dialog
442 open={elementsOpen}
443 onOpenChange={(nextOpen) => {
444 if (!nextOpen) closeElementsDialog()
445 else setElementsOpen(true)
446 }}
447 >
448 <DialogContent
449 showClose={false}
450 className="!max-w-[960px] h-[600px] gap-4 overflow-y-auto p-5"
451 >
452 <Button
453 type="button"
454 variant="ghost"
455 size="sm"
456 className="absolute right-3 top-3 h-7 w-7 p-0"
457 aria-label={t('common.cancel')}
458 disabled={saving}
459 onClick={closeElementsDialog}
460 >
461 <X className="h-4 w-4" />
462 </Button>
463 <DialogHeader>
464 <DialogTitle>{t('sessionDetail.masterElementsTitle')}</DialogTitle>
465 <DialogDescription>{t('sessionDetail.masterElementsDescription')}</DialogDescription>
466 </DialogHeader>
467
468 {loading ? (
469 <div className="flex justify-center py-10 text-[#667257]">
470 <Loader2 className="h-5 w-5 animate-spin" />
471 </div>
472 ) : (
473 <fieldset disabled={busy} className="space-y-5">
474 <MasterElementsEditor />
475
476 {selectedPageId && (
477 <div className="flex w-fit items-center gap-2 text-sm text-[#4a563d]">
478 <label htmlFor="master-hide-elements-on-slide" className="cursor-pointer">
479 {t('sessionDetail.masterHideElementsOnSlide')}
480 </label>
481 <Checkbox
482 id="master-hide-elements-on-slide"
483 checked={currentPageElementsDisabled}
484 onCheckedChange={(checked) => void toggleCurrentPageElements(checked === true)}
485 />
486 </div>
487 )}
488
489 {status && status.unlinkedPageCount > 0 && (
490 <p className="text-xs leading-4 text-[#667257]">
491 {t('sessionDetail.masterUnlinkedHint', { count: status.unlinkedPageCount })}
492 </p>
493 )}
494 {error && <p className="text-xs leading-4 text-[#a14f4a]">{error}</p>}
495 </fieldset>
496 )}
497
498 <DialogFooter>
499 <Button
500 type="button"
501 variant="ghost"
502 size="sm"
503 disabled={saving}
504 onClick={closeElementsDialog}
505 >
506 {t('common.cancel')}
507 </Button>
508 <Button
509 type="button"
510 variant="outline"
511 size="sm"
512 disabled={busy || loading || (status?.missingPageCount || 0) > 0}
513 onClick={() => updateConfig({ elements: buildDefaultMasterElementsConfig() })}
514 >
515 {t('sessionDetail.masterElementsReset')}
516 </Button>
517 <Button
518 type="button"
519 size="sm"
520 disabled={busy || loading}
521 onClick={() => void saveMaster()}
522 >
523 {saving ? t('common.saving') : t('sessionDetail.masterSaveAndApply')}
524 </Button>
525 </DialogFooter>
526 </DialogContent>
527 </Dialog>
528 </>
529 )
530 }
531
531 lines Plain Text