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