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