返回 oh-my-ppt
MergeTemplatePagesDialog.tsx
根目录 / src / renderer / src / components / session-detail / modal / MergeTemplatePagesDialog.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2 import { LayoutTemplate, Loader2, Search } from 'lucide-react'
3 import { useT } from '@renderer/i18n'
4 import {
5 ipc,
6 type MergeSourcePageSummary,
7 type MergeTemplateSourceSummary
8 } from '@renderer/lib/ipc'
9 import { useGenerateStore, useSessionDetailUiStore, useToastStore } from '@renderer/store'
10 import { localAssetUrl } from '@shared/local-asset'
11 import { readPageMergeErrorCode, type PageMergeDisabledReason } from '@shared/page-merge'
12 import { requireSlideSize } from '@shared/slide-size'
13 import { PreviewIframe } from '../../preview/PreviewIframe'
14 import { Button } from '../../ui/Button'
15 import { Checkbox } from '../../ui/Checkbox'
16 import {
17 Dialog,
18 DialogContent,
19 DialogDescription,
20 DialogFooter,
21 DialogHeader,
22 DialogTitle
23 } from '../../ui/Dialog'
24 import { Input } from '../../ui/Input'
25 import { ScrollArea } from '../../ui/ScrollArea'
26 import { usePreviewWindow } from '../hooks/usePreviewWindow'
27
28 interface MergeTemplatePagesDialogProps {
29 sessionId: string
30 }
31
32 const MAX_MERGE_PAGE_COUNT = 50
33 const MERGE_PREVIEW_LIMIT = 6
34
35 function TemplatePagePreview({
36 page,
37 renderPreview
38 }: {
39 page: MergeSourcePageSummary
40 renderPreview: boolean
41 }): React.JSX.Element {
42 const slideSize = requireSlideSize({
43 id: page.slideSizeId,
44 width: page.slideWidth,
45 height: page.slideHeight
46 })
47
48 return (
49 <div
50 className="h-full w-full overflow-hidden"
51 style={{ aspectRatio: `${slideSize.width}/${slideSize.height}` }}
52 >
53 {renderPreview && (page.htmlPath || page.sourceUrl) ? (
54 <PreviewIframe
55 src={page.sourceUrl}
56 htmlPath={page.htmlPath}
57 pageId={page.pageId}
58 title={`merge-template-page-${page.pageNumber}`}
59 slideSize={slideSize}
60 inspectable={false}
61 thumbnail
62 />
63 ) : (
64 <div className="flex h-full items-center justify-center text-xs text-[#8a806f]">
65 P{page.pageNumber}
66 </div>
67 )}
68 </div>
69 )
70 }
71
72 export function MergeTemplatePagesDialog({
73 sessionId
74 }: MergeTemplatePagesDialogProps): React.JSX.Element {
75 const t = useT()
76 const open = useSessionDetailUiStore((state) => state.mergeTemplatePagesDialogOpen)
77 const setOpen = useSessionDetailUiStore((state) => state.setMergeTemplatePagesDialogOpen)
78 const setIsAddingPage = useSessionDetailUiStore((state) => state.setIsAddingPage)
79 const toastSuccess = useToastStore((state) => state.success)
80 const toastError = useToastStore((state) => state.error)
81 const toastWarning = useToastStore((state) => state.warning)
82 const [query, setQuery] = useState('')
83 const [sourceTemplates, setSourceTemplates] = useState<MergeTemplateSourceSummary[]>([])
84 const [selectedTemplateId, setSelectedTemplateId] = useState('')
85 const [sourcePages, setSourcePages] = useState<MergeSourcePageSummary[]>([])
86 const [selectedSourcePageIds, setSelectedSourcePageIds] = useState<Set<string>>(() => new Set())
87 const [loadingTemplates, setLoadingTemplates] = useState(false)
88 const [loadingPages, setLoadingPages] = useState(false)
89 const [submitting, setSubmitting] = useState(false)
90 const [loadError, setLoadError] = useState('')
91 const pageRequestIdRef = useRef(0)
92
93 const getRequestErrorMessage = useCallback(
94 (
95 error: unknown,
96 fallbackKey:
97 | 'sessionDetail.mergeTemplateLoadFailed'
98 | 'sessionDetail.mergeTemplatePagesFailed'
99 ): string => {
100 switch (readPageMergeErrorCode(error)) {
101 case 'PAGE_MERGE_INVALID_REQUEST':
102 return t('sessionDetail.mergeErrorInvalidRequest')
103 case 'PAGE_MERGE_NO_PAGE_SELECTED':
104 return t('sessionDetail.mergeErrorNoPageSelected')
105 case 'PAGE_MERGE_PAGE_LIMIT_EXCEEDED':
106 return t('sessionDetail.mergeErrorPageLimit')
107 case 'PAGE_MERGE_SESSION_NOT_FOUND':
108 return t('sessionDetail.mergeErrorSessionNotFound')
109 case 'PAGE_MERGE_SLIDE_SIZE_MISMATCH':
110 return t('sessionDetail.mergeErrorSlideSizeMismatch')
111 case 'PAGE_MERGE_SOURCE_PAGE_NOT_FOUND':
112 return t('sessionDetail.mergeErrorPageNotFound')
113 case 'PAGE_MERGE_SOURCE_PAGE_UNAVAILABLE':
114 return t('sessionDetail.mergeErrorPageUnavailable')
115 case 'PAGE_MERGE_TARGET_FONT_UNAVAILABLE':
116 return t('sessionDetail.mergeErrorTargetFont')
117 case 'PAGE_MERGE_PAGE_COPY_FAILED':
118 return t('sessionDetail.mergeErrorPageCopy')
119 default:
120 return t(fallbackKey)
121 }
122 },
123 [t]
124 )
125
126 const getDisabledReason = useCallback(
127 (reason?: PageMergeDisabledReason): string => {
128 switch (reason) {
129 case 'PAGE_MERGE_SESSION_EMPTY':
130 return t('sessionDetail.mergeDisabledSessionEmpty')
131 case 'PAGE_MERGE_SLIDE_SIZE_MISMATCH':
132 return t('sessionDetail.mergeDisabledSlideSizeMismatch')
133 case 'PAGE_MERGE_PAGE_FILE_MISSING':
134 return t('sessionDetail.mergeDisabledPageMissing')
135 default:
136 return ''
137 }
138 },
139 [t]
140 )
141
142 const selectedTemplate = sourceTemplates.find((item) => item.id === selectedTemplateId)
143 const filteredTemplates = useMemo(() => {
144 const keyword = query.trim().toLocaleLowerCase()
145 if (!keyword) return sourceTemplates
146 return sourceTemplates.filter((item) => item.title.toLocaleLowerCase().includes(keyword))
147 }, [query, sourceTemplates])
148 const selectablePageIds = useMemo(
149 () => sourcePages.filter((page) => page.selectable).map((page) => page.id),
150 [sourcePages]
151 )
152 const sourcePageIds = useMemo(() => sourcePages.map((page) => page.id), [sourcePages])
153 const selectablePageCount = Math.min(selectablePageIds.length, MAX_MERGE_PAGE_COUNT)
154 const allSelectablePagesSelected =
155 selectablePageCount > 0 && selectedSourcePageIds.size === selectablePageCount
156 const {
157 activePreviewIds: previewPageIds,
158 viewportRef: pageViewportRef,
159 schedulePreviewWindowUpdate
160 } = usePreviewWindow({
161 enabled: open && !loadingPages && sourcePages.length > 0,
162 itemIds: sourcePageIds,
163 limit: MERGE_PREVIEW_LIMIT
164 })
165
166 useEffect(() => {
167 if (!open || !sessionId) return
168 let cancelled = false
169 setLoadingTemplates(true)
170 setLoadError('')
171 void ipc
172 .listMergeSourceTemplates({ targetSessionId: sessionId })
173 .then((templates) => {
174 if (cancelled) return
175 setSourceTemplates(templates)
176 })
177 .catch((error) => {
178 if (cancelled) return
179 setLoadError(getRequestErrorMessage(error, 'sessionDetail.mergeTemplateLoadFailed'))
180 })
181 .finally(() => {
182 if (!cancelled) setLoadingTemplates(false)
183 })
184 return () => {
185 cancelled = true
186 }
187 }, [getRequestErrorMessage, open, sessionId])
188
189 useEffect(() => {
190 if (open) return
191 pageRequestIdRef.current += 1
192 setQuery('')
193 setSourceTemplates([])
194 setSelectedTemplateId('')
195 setSourcePages([])
196 setSelectedSourcePageIds(new Set())
197 setLoadingTemplates(false)
198 setLoadingPages(false)
199 setSubmitting(false)
200 setLoadError('')
201 }, [open])
202
203 const handleSelectTemplate = async (
204 template: MergeTemplateSourceSummary
205 ): Promise<void> => {
206 if (!template.selectable || loadingPages || submitting) return
207 const requestId = pageRequestIdRef.current + 1
208 pageRequestIdRef.current = requestId
209 setSelectedTemplateId(template.id)
210 setSelectedSourcePageIds(new Set())
211 setSourcePages([])
212 setLoadingPages(true)
213 setLoadError('')
214 try {
215 const pages = await ipc.listMergeSourceTemplatePages({
216 targetSessionId: sessionId,
217 templateId: template.id
218 })
219 if (pageRequestIdRef.current !== requestId) return
220 setSourcePages(pages)
221 } catch (error) {
222 if (pageRequestIdRef.current !== requestId) return
223 setLoadError(getRequestErrorMessage(error, 'sessionDetail.mergeTemplateLoadFailed'))
224 } finally {
225 if (pageRequestIdRef.current === requestId) setLoadingPages(false)
226 }
227 }
228
229 const togglePage = (page: MergeSourcePageSummary): void => {
230 if (!page.selectable || submitting) return
231 setSelectedSourcePageIds((current) => {
232 const next = new Set(current)
233 if (next.has(page.id)) next.delete(page.id)
234 else if (next.size < MAX_MERGE_PAGE_COUNT) next.add(page.id)
235 else toastWarning(t('sessionDetail.mergePageLimitReached', { count: MAX_MERGE_PAGE_COUNT }))
236 return next
237 })
238 }
239
240 const handleToggleAll = (): void => {
241 setSelectedSourcePageIds((current) =>
242 current.size === selectablePageCount
243 ? new Set()
244 : new Set(selectablePageIds.slice(0, MAX_MERGE_PAGE_COUNT))
245 )
246 }
247
248 const handleConfirm = async (): Promise<void> => {
249 if (!selectedTemplateId || selectedSourcePageIds.size === 0 || submitting) return
250 setSubmitting(true)
251 setIsAddingPage(true)
252 try {
253 const result = await ipc.mergeTemplatePages({
254 targetSessionId: sessionId,
255 templateId: selectedTemplateId,
256 sourcePageIds: Array.from(selectedSourcePageIds)
257 })
258 useGenerateStore.getState().setPages(result.generatedPages)
259 useSessionDetailUiStore.getState().bumpPreviewKey()
260 useSessionDetailUiStore.getState().finishAddPage(result.selectedPageId)
261 void ipc
262 .clearSpeechScript(sessionId)
263 .catch((error) => console.warn('[speech] clearSpeechScript failed', error))
264 toastSuccess(
265 t('sessionDetail.mergeTemplateSuccess', {
266 template: selectedTemplate?.title || t('sessionDetail.mergeTemplateUntitled'),
267 count: result.insertedPageIds.length
268 })
269 )
270 setOpen(false)
271 } catch (error) {
272 useSessionDetailUiStore.getState().finishAddPage(undefined)
273 toastError(getRequestErrorMessage(error, 'sessionDetail.mergeTemplatePagesFailed'))
274 } finally {
275 setSubmitting(false)
276 }
277 }
278
279 return (
280 <Dialog
281 open={open}
282 onOpenChange={(nextOpen) => {
283 if (!submitting) setOpen(nextOpen)
284 }}
285 >
286 <DialogContent
287 aria-busy={submitting}
288 onEscapeKeyDown={(event) => {
289 if (submitting) event.preventDefault()
290 }}
291 onPointerDownOutside={(event) => {
292 if (submitting) event.preventDefault()
293 }}
294 onInteractOutside={(event) => {
295 if (submitting) event.preventDefault()
296 }}
297 className="h-[min(760px,82vh)] max-w-[960px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 overflow-hidden p-5"
298 showClose={!submitting}
299 >
300 <fieldset disabled={submitting} className="contents disabled:pointer-events-none">
301 <DialogHeader>
302 <DialogTitle>{t('sessionDetail.mergeTemplateTitle')}</DialogTitle>
303 <DialogDescription>{t('sessionDetail.mergeTemplateDescription')}</DialogDescription>
304 </DialogHeader>
305
306 <div className="grid min-h-0 grid-cols-[280px_minmax(0,1fr)] overflow-hidden rounded-2xl border border-[#d8cfbc]/75 bg-[#f8f4eb]">
307 <div className="flex min-h-0 flex-col border-r border-[#d8cfbc]/75 bg-[#f2ecdf]/75 p-3">
308 <div className="relative mb-3">
309 <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#829071]" />
310 <Input
311 value={query}
312 onChange={(event) => setQuery(event.target.value)}
313 placeholder={t('sessionDetail.mergeTemplateSearchTemplates')}
314 className="h-9 bg-[#fffaf1] pl-9"
315 disabled={submitting}
316 />
317 </div>
318 <ScrollArea className="min-h-0 flex-1" viewportClassName="pr-2">
319 {loadingTemplates ? (
320 <div className="flex h-32 items-center justify-center text-[#7a875f]">
321 <Loader2 className="h-5 w-5 animate-spin" />
322 </div>
323 ) : filteredTemplates.length === 0 ? (
324 <div className="rounded-xl border border-dashed border-[#c9c0ae] px-3 py-8 text-center text-xs text-[#8a806f]">
325 {t('sessionDetail.mergeTemplateNoTemplates')}
326 </div>
327 ) : (
328 <div className="space-y-2">
329 {filteredTemplates.map((template) => {
330 const selected = template.id === selectedTemplateId
331 const slideSize = requireSlideSize({
332 id: template.slideSizeId,
333 width: template.slideWidth,
334 height: template.slideHeight
335 })
336 return (
337 <button
338 key={template.id}
339 type="button"
340 disabled={!template.selectable || loadingPages || submitting}
341 onClick={() => void handleSelectTemplate(template)}
342 className={`flex w-full gap-2.5 rounded-xl border p-2 text-left transition-colors ${
343 selected
344 ? 'border-[#8eaa70] bg-[#e8f1dd] shadow-sm'
345 : 'border-[#ddd4c4] bg-[#fffaf1]/80 hover:bg-white'
346 } disabled:cursor-not-allowed disabled:opacity-55`}
347 >
348 <div
349 className="relative h-14 w-20 shrink-0 overflow-hidden rounded-md bg-[#eee7d9]"
350 style={{ aspectRatio: `${slideSize.width}/${slideSize.height}` }}
351 >
352 {template.thumbnailPath ? (
353 <img
354 src={localAssetUrl(template.thumbnailPath)}
355 alt={template.title}
356 className="h-full w-full object-cover"
357 />
358 ) : (
359 <div className="flex h-full items-center justify-center text-[#a39a86]">
360 <LayoutTemplate className="h-5 w-5" />
361 </div>
362 )}
363 </div>
364 <div className="min-w-0 flex-1">
365 <div className="flex items-center gap-1.5">
366 <span className="truncate text-sm font-semibold text-[#3e4a32]">
367 {template.title || t('sessionDetail.mergeTemplateUntitled')}
368 </span>
369 {template.isSource ? (
370 <span className="shrink-0 rounded bg-[#5d6b4d] px-1 py-0.5 text-[9px] font-bold text-white">
371 {t('sessionDetail.mergeTemplateSourceBadge')}
372 </span>
373 ) : null}
374 </div>
375 <div className="mt-1 text-[11px] text-[#817766]">
376 {t('sessionDetail.mergeTemplatePageCount', {
377 count: template.pageCount
378 })}
379 </div>
380 {template.disabledReason ? (
381 <div className="mt-1 text-[10px] text-[#a1665c]">
382 {getDisabledReason(template.disabledReason)}
383 </div>
384 ) : null}
385 </div>
386 </button>
387 )
388 })}
389 </div>
390 )}
391 </ScrollArea>
392 </div>
393
394 <div className="flex min-h-0 flex-col bg-[#fffaf1]/75 p-4">
395 <div className="mb-3 flex min-h-9 items-center justify-between gap-3">
396 <div className="min-w-0">
397 <div className="truncate text-sm font-semibold text-[#3e4a32]">
398 {selectedTemplate
399 ? selectedTemplate.title || t('sessionDetail.mergeTemplateUntitled')
400 : t('sessionDetail.mergeTemplateSelectTemplate')}
401 </div>
402 {selectedTemplate ? (
403 <div className="mt-0.5 text-[11px] text-[#817766]">
404 {t('sessionDetail.mergeTemplateSelectedCount', {
405 count: selectedSourcePageIds.size,
406 max: selectablePageIds.length
407 })}
408 </div>
409 ) : null}
410 </div>
411 {selectedTemplate && sourcePages.length > 0 ? (
412 <label className="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium text-[#5d6b4d] hover:bg-[#e8e0d0]/70">
413 <Checkbox
414 checked={allSelectablePagesSelected}
415 disabled={submitting || selectablePageIds.length === 0}
416 onCheckedChange={handleToggleAll}
417 />
418 {selectablePageIds.length > MAX_MERGE_PAGE_COUNT
419 ? t('sessionDetail.mergeTemplateSelectFirstPages', {
420 count: MAX_MERGE_PAGE_COUNT
421 })
422 : t('sessionDetail.mergeTemplateSelectAll')}
423 </label>
424 ) : null}
425 </div>
426
427 <ScrollArea
428 className="min-h-0 flex-1"
429 viewportClassName="pr-2"
430 viewportRef={pageViewportRef}
431 onViewportScroll={schedulePreviewWindowUpdate}
432 >
433 {loadingPages ? (
434 <div className="flex h-full min-h-56 items-center justify-center text-[#7a875f]">
435 <Loader2 className="h-6 w-6 animate-spin" />
436 </div>
437 ) : loadError ? (
438 <div className="flex h-full min-h-56 items-center justify-center rounded-xl border border-dashed border-[#d8b4aa] px-6 text-center text-sm text-[#a15f55]">
439 {loadError}
440 </div>
441 ) : !selectedTemplate ? (
442 <div className="flex h-full min-h-56 flex-col items-center justify-center rounded-xl border border-dashed border-[#d6cdbd] text-[#8a806f]">
443 <LayoutTemplate className="mb-3 h-8 w-8 text-[#9eaa8e]" />
444 <span className="text-sm">
445 {t('sessionDetail.mergeTemplateSelectTemplateHint')}
446 </span>
447 </div>
448 ) : sourcePages.length === 0 ? (
449 <div className="flex h-full min-h-56 items-center justify-center text-sm text-[#8a806f]">
450 {t('sessionDetail.mergeTemplateNoPages')}
451 </div>
452 ) : (
453 <div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
454 {sourcePages.map((page) => {
455 const selected = selectedSourcePageIds.has(page.id)
456 const slideSize = requireSlideSize({
457 id: page.slideSizeId,
458 width: page.slideWidth,
459 height: page.slideHeight
460 })
461 return (
462 <div
463 key={page.id}
464 data-preview-window-id={page.id}
465 aria-disabled={!page.selectable || submitting}
466 onClick={() => {
467 if (page.selectable && !submitting) togglePage(page)
468 }}
469 className={`group relative overflow-hidden rounded-xl border p-2 text-left transition-all ${
470 selected
471 ? 'border-[#7f9f67] bg-[#e9f2df] shadow-[0_8px_18px_rgba(93,107,77,0.14)]'
472 : 'border-[#ddd4c4] bg-white hover:border-[#b8c7a5]'
473 } ${
474 !page.selectable || submitting
475 ? 'cursor-not-allowed opacity-55'
476 : 'cursor-pointer'
477 }`}
478 >
479 <div
480 className="relative w-full overflow-hidden rounded-lg bg-[#eee7d9]"
481 style={{ aspectRatio: `${slideSize.width}/${slideSize.height}` }}
482 >
483 <TemplatePagePreview
484 page={page}
485 renderPreview={previewPageIds.has(page.id)}
486 />
487 <span className="absolute left-2 top-2 z-10 rounded-md bg-[#fffaf1]/92 px-1.5 py-0.5 text-[10px] font-semibold text-[#4f613f] shadow-sm">
488 P{page.pageNumber}
489 </span>
490 <span
491 className="absolute right-2 top-2 z-10 rounded bg-[#fffaf1]/92 p-1 shadow-sm"
492 onClick={(event) => event.stopPropagation()}
493 onKeyDown={(event) => event.stopPropagation()}
494 >
495 <Checkbox
496 checked={selected}
497 disabled={!page.selectable || submitting}
498 aria-label={page.title}
499 onCheckedChange={() => togglePage(page)}
500 />
501 </span>
502 </div>
503 <div className="mt-2 line-clamp-2 min-h-8 text-xs font-medium leading-4 text-[#4a583f]">
504 {page.title || t('sessionDetail.untitledPage')}
505 </div>
506 {page.disabledReason ? (
507 <div className="mt-1 text-[10px] text-[#a1665c]">
508 {getDisabledReason(page.disabledReason)}
509 </div>
510 ) : null}
511 </div>
512 )
513 })}
514 </div>
515 )}
516 </ScrollArea>
517 </div>
518 </div>
519
520 <DialogFooter>
521 <Button variant="ghost" disabled={submitting} onClick={() => setOpen(false)}>
522 {t('common.cancel')}
523 </Button>
524 <Button
525 disabled={!selectedTemplateId || selectedSourcePageIds.size === 0 || submitting}
526 onClick={() => void handleConfirm()}
527 >
528 {submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
529 {submitting
530 ? t('sessionDetail.mergeTemplateAddingPages')
531 : t('sessionDetail.mergeTemplateAddPages', { count: selectedSourcePageIds.size })}
532 </Button>
533 </DialogFooter>
534 </fieldset>
535 </DialogContent>
536 </Dialog>
537 )
538 }
539
539 lines Plain Text