返回 oh-my-ppt
useSessionExportActions.ts
根目录 / src / renderer / src / components / session-detail / hooks / useSessionExportActions.ts
1 import { useMemo } from 'react'
2 import { ipc } from '@renderer/lib/ipc'
3 import {
4 useGenerateStore,
5 useSessionDetailUiStore,
6 useSessionStore,
7 useToastStore
8 } from '@renderer/store'
9 import { useT } from '@renderer/i18n'
10 import { normalizePagesForSelection } from '../shared/pageUtils'
11 import { startExportProgressToast } from './exportProgressToast'
12 import type { ExportKind, ExportProgressPayload } from '@shared/export-progress.js'
13 import { isDefaultSlideSize, trySessionSlideSize } from '@shared/slide-size'
14
15 type PptxExportOptions = {
16 imageOnly?: boolean
17 embedFonts?: boolean | 'auto' | 'always' | 'never'
18 pageId?: string
19 }
20
21 type VideoExportOptions = {
22 pageId?: string
23 }
24
25 type ExportProgressToastController = ReturnType<typeof startExportProgressToast>
26
27 function getPptxExportNotice(
28 warnings: string[] | undefined,
29 t: ReturnType<typeof useT>
30 ): string | null {
31 const items = (warnings || []).filter(Boolean)
32 if (items.length === 0) return null
33
34 const hasPageLoadDelay = items.some((item) => item.includes('未收到打印就绪信号'))
35 if (hasPageLoadDelay) return t('sessionDetail.pageLoadNotice')
36
37 const hasNoEditableText = items.some((item) => item.includes('未提取到可编辑文本'))
38 if (hasNoEditableText) return t('sessionDetail.noEditableTextNotice')
39
40 const hasOnlyCapabilityNote = items.every(
41 (item) =>
42 item.includes('自研') ||
43 item.includes('pptxgenjs') ||
44 item.includes('HTML 解析器') ||
45 item.includes('文本层')
46 )
47 if (hasOnlyCapabilityNote) return null
48
49 return t('sessionDetail.exportCheckNotice')
50 }
51
52 export function useSessionExportActions(sessionId: string): {
53 exportPdf: () => Promise<void>
54 exportPng: () => Promise<void>
55 exportLongImage: () => Promise<void>
56 exportVideo: (options?: VideoExportOptions) => Promise<void>
57 exportPptx: (options?: PptxExportOptions) => Promise<void>
58 canExportPptx: boolean
59 exportSlidePack: () => Promise<void>
60 exportSessionZip: () => Promise<void>
61 exportOutlinesMarkdown: () => Promise<void>
62 openProjectPreview: () => Promise<void>
63 revealSelectedPageFile: () => Promise<void>
64 openPresentation: () => Promise<void>
65 } {
66 const t = useT()
67 const {
68 success: toastSuccess,
69 error: toastError,
70 info: toastInfo,
71 loading: toastLoading
72 } = useToastStore()
73 const progressToastApi = useMemo(
74 () => ({
75 loading: toastLoading,
76 success: toastSuccess,
77 info: toastInfo,
78 error: toastError
79 }),
80 [toastError, toastInfo, toastLoading, toastSuccess]
81 )
82 const selectedPageId = useSessionDetailUiStore((state) => state.selectedPageId)
83 const currentPages = useGenerateStore((state) => state.currentPages)
84 const currentSession = useSessionStore((state) => state.currentSession)
85 const slideSize = trySessionSlideSize(currentSession)
86 const canExportPptx = slideSize ? isDefaultSlideSize(slideSize) : false
87
88 const pages = useMemo(() => normalizePagesForSelection(currentPages), [currentPages])
89 const selectedPage = useMemo(
90 () => pages.find((page) => page.id === selectedPageId) ?? pages[0] ?? null,
91 [pages, selectedPageId]
92 )
93
94 const describeExportProgress = (
95 payload: ExportProgressPayload,
96 fallbackDescription: string
97 ): string => {
98 if (
99 payload.stage === 'rendering' &&
100 typeof payload.current === 'number' &&
101 typeof payload.total === 'number' &&
102 payload.total > 0
103 ) {
104 return t('sessionDetail.exportProgressRendering', {
105 current: payload.current,
106 total: payload.total
107 })
108 }
109 if (payload.stage === 'preparing') return t('sessionDetail.exportProgressPreparing')
110 if (payload.stage === 'packaging') return t('sessionDetail.exportProgressPackaging')
111 if (payload.stage === 'writing') return t('sessionDetail.exportProgressWriting')
112 return fallbackDescription
113 }
114
115 const createExportProgressToast = ({
116 kind,
117 title,
118 description
119 }: {
120 kind: ExportKind
121 title: string
122 description: string
123 }): {
124 success: (
125 message: string,
126 options?: Parameters<ExportProgressToastController['success']>[1]
127 ) => void
128 cancel: (message: string) => void
129 error: (message: string) => void
130 dispose: () => void
131 } => {
132 let progressToast: ExportProgressToastController | null = null
133 let closed = false
134 const unsubscribe = ipc.onExportProgress((payload) => {
135 if (payload.sessionId !== sessionId || payload.kind !== kind) return
136 const progressDescription = describeExportProgress(payload, description)
137 if (!progressToast) {
138 progressToast = startExportProgressToast({
139 toast: progressToastApi,
140 title,
141 description: progressDescription,
142 initialProgress: payload.progress
143 })
144 return
145 }
146 progressToast.update({
147 progress: payload.progress,
148 description: progressDescription
149 })
150 })
151
152 const close = (): void => {
153 if (closed) return
154 closed = true
155 unsubscribe()
156 }
157
158 return {
159 success: (message, options) => {
160 close()
161 if (progressToast) {
162 progressToast.success(message, options)
163 return
164 }
165 toastSuccess(message, options)
166 },
167 cancel: (message) => {
168 close()
169 if (progressToast) {
170 progressToast.cancel(message)
171 return
172 }
173 toastInfo(message)
174 },
175 error: (message) => {
176 close()
177 if (progressToast) {
178 progressToast.error(message)
179 return
180 }
181 toastError(message)
182 },
183 dispose: () => {
184 close()
185 progressToast?.dispose()
186 }
187 }
188 }
189
190 const exportPdf = async (): Promise<void> => {
191 const detailState = useSessionDetailUiStore.getState()
192 if (!sessionId || detailState.isExportingPdf) return
193 detailState.setIsExportingPdf(true)
194 const progressToast = createExportProgressToast({
195 kind: 'pdf',
196 title: t('sessionDetail.exportPdfStart'),
197 description: t('sessionDetail.exportPdfDescription')
198 })
199 try {
200 const result = await ipc.exportPdf(sessionId)
201 if (result.cancelled) {
202 progressToast.cancel(t('sessionDetail.exportCancelled'))
203 return
204 }
205 if (!result.success || !result.path) {
206 progressToast.error(t('sessionDetail.exportFailed'))
207 return
208 }
209 if (Array.isArray(result.warnings) && result.warnings.length > 0) {
210 progressToast.success(
211 t('sessionDetail.exportSuccessPages', { count: result.pageCount || 0 }),
212 {
213 description: result.warnings[0]
214 }
215 )
216 return
217 }
218 progressToast.success(t('sessionDetail.exportSuccessPages', { count: result.pageCount || 0 }))
219 } catch (error) {
220 progressToast.error(error instanceof Error ? error.message : t('sessionDetail.exportFailed'))
221 } finally {
222 progressToast.dispose()
223 useSessionDetailUiStore.getState().setIsExportingPdf(false)
224 }
225 }
226
227 const exportPng = async (): Promise<void> => {
228 const detailState = useSessionDetailUiStore.getState()
229 if (!sessionId || detailState.isExportingPng) return
230 detailState.setIsExportingPng(true)
231 const progressToast = createExportProgressToast({
232 kind: 'png',
233 title: t('sessionDetail.exportPngStart'),
234 description: t('sessionDetail.exportPngDescription')
235 })
236 try {
237 const result = await ipc.exportPng(sessionId)
238 if (result.cancelled) {
239 progressToast.cancel(t('sessionDetail.exportCancelled'))
240 return
241 }
242 if (!result.success || !result.path) {
243 progressToast.error(t('sessionDetail.exportFailed'))
244 return
245 }
246 if (Array.isArray(result.warnings) && result.warnings.length > 0) {
247 progressToast.success(t('sessionDetail.pngExported', { count: result.pageCount || 0 }), {
248 description: t('sessionDetail.pageLoadNotice')
249 })
250 return
251 }
252 progressToast.success(t('sessionDetail.pngExported', { count: result.pageCount || 0 }))
253 } catch (error) {
254 progressToast.error(error instanceof Error ? error.message : t('sessionDetail.exportFailed'))
255 } finally {
256 progressToast.dispose()
257 useSessionDetailUiStore.getState().setIsExportingPng(false)
258 }
259 }
260
261 const exportLongImage = async (): Promise<void> => {
262 const detailState = useSessionDetailUiStore.getState()
263 if (!sessionId || detailState.isExportingLongImage) return
264 detailState.setIsExportingLongImage(true)
265 const progressToast = createExportProgressToast({
266 kind: 'longImage',
267 title: t('sessionDetail.exportLongImageStart'),
268 description: t('sessionDetail.exportLongImageDescription')
269 })
270 try {
271 const result = await ipc.exportLongImage(sessionId)
272 if (result.cancelled) {
273 progressToast.cancel(t('sessionDetail.exportCancelled'))
274 return
275 }
276 if (!result.success || !result.path) {
277 progressToast.error(t('sessionDetail.exportFailed'))
278 return
279 }
280 if (Array.isArray(result.warnings) && result.warnings.length > 0) {
281 progressToast.success(
282 t('sessionDetail.longImageExported', { count: result.pageCount || 0 }),
283 {
284 description: t('sessionDetail.pageLoadNotice')
285 }
286 )
287 return
288 }
289 progressToast.success(t('sessionDetail.longImageExported', { count: result.pageCount || 0 }))
290 } catch (error) {
291 progressToast.error(error instanceof Error ? error.message : t('sessionDetail.exportFailed'))
292 } finally {
293 progressToast.dispose()
294 useSessionDetailUiStore.getState().setIsExportingLongImage(false)
295 }
296 }
297
298 const exportVideo = async (options?: VideoExportOptions): Promise<void> => {
299 const detailState = useSessionDetailUiStore.getState()
300 if (!sessionId || detailState.isExportingVideo) return
301 detailState.setIsExportingVideo(true)
302 const progressToast = createExportProgressToast({
303 kind: 'video',
304 title: t('sessionDetail.exportVideoStart'),
305 description: t('sessionDetail.exportVideoDescription')
306 })
307 try {
308 const result = await ipc.exportVideo(sessionId, options)
309 if (result.cancelled) {
310 progressToast.cancel(t('sessionDetail.exportCancelled'))
311 return
312 }
313 if (!result.success || !result.path) {
314 progressToast.error(t('sessionDetail.exportFailed'))
315 return
316 }
317 if (Array.isArray(result.warnings) && result.warnings.length > 0) {
318 progressToast.success(t('sessionDetail.videoExported', { count: result.pageCount || 0 }), {
319 description: result.warnings[0]
320 })
321 return
322 }
323 progressToast.success(t('sessionDetail.videoExported', { count: result.pageCount || 0 }))
324 } catch (error) {
325 progressToast.error(error instanceof Error ? error.message : t('sessionDetail.exportFailed'))
326 } finally {
327 progressToast.dispose()
328 useSessionDetailUiStore.getState().setIsExportingVideo(false)
329 }
330 }
331
332 const exportPptx = async (options?: PptxExportOptions): Promise<void> => {
333 const detailState = useSessionDetailUiStore.getState()
334 if (!sessionId || detailState.isExportingPptx) return
335 if (!canExportPptx) {
336 toastError('当前 PPTX 导出仅支持 16:9。请导出 PNG、PDF 或视频。')
337 return
338 }
339 const imageOnly = options?.imageOnly === true
340 detailState.setIsExportingPptx(true)
341 const progressToast = createExportProgressToast({
342 kind: 'pptx',
343 title: t(
344 imageOnly ? 'sessionDetail.pptxPreparingImage' : 'sessionDetail.pptxPreparingEditable'
345 ),
346 description: t(
347 imageOnly
348 ? 'sessionDetail.pptxPreparingImageDescription'
349 : 'sessionDetail.pptxPreparingEditableDescription'
350 )
351 })
352 try {
353 const result = await ipc.exportPptx(sessionId, options)
354 if (result.cancelled) {
355 progressToast.cancel(t('sessionDetail.exportCancelled'))
356 return
357 }
358 if (!result.success || !result.path) {
359 progressToast.error(t('sessionDetail.exportFailed'))
360 return
361 }
362 const exportNotice = getPptxExportNotice(result.warnings, t)
363 if (exportNotice) {
364 progressToast.success(t('sessionDetail.pptxExported', { count: result.pageCount || 0 }), {
365 description: exportNotice
366 })
367 return
368 }
369 progressToast.success(t('sessionDetail.pptxExported', { count: result.pageCount || 0 }), {
370 description: t(
371 imageOnly ? 'sessionDetail.pptxImageDescription' : 'sessionDetail.pptxEditableDescription'
372 )
373 })
374 } catch (error) {
375 progressToast.error(error instanceof Error ? error.message : t('sessionDetail.exportFailed'))
376 } finally {
377 progressToast.dispose()
378 useSessionDetailUiStore.getState().setIsExportingPptx(false)
379 }
380 }
381
382 const exportSlidePack = async (): Promise<void> => {
383 const detailState = useSessionDetailUiStore.getState()
384 if (!sessionId || detailState.isExportingSlidePack) return
385 detailState.setIsExportingSlidePack(true)
386 const progressToast = createExportProgressToast({
387 kind: 'slidePack',
388 title: t('sessionDetail.slidePackPreparing'),
389 description: t('sessionDetail.slidePackPreparingDescription')
390 })
391 try {
392 const result = await ipc.exportSlidePack(sessionId)
393 if (result.cancelled) {
394 progressToast.cancel(t('sessionDetail.exportCancelled'))
395 return
396 }
397 if (!result.success || !result.path) {
398 progressToast.error(t('sessionDetail.exportFailed'))
399 return
400 }
401 progressToast.success(t('sessionDetail.slidePackExported'), {
402 description: t('sessionDetail.slidePackExportedDescription')
403 })
404 } catch (error) {
405 progressToast.error(error instanceof Error ? error.message : t('sessionDetail.exportFailed'))
406 } finally {
407 progressToast.dispose()
408 useSessionDetailUiStore.getState().setIsExportingSlidePack(false)
409 }
410 }
411
412 const exportSessionZip = async (): Promise<void> => {
413 const detailState = useSessionDetailUiStore.getState()
414 if (!sessionId || detailState.isExportingSessionZip) return
415 detailState.setIsExportingSessionZip(true)
416 const progressToast = createExportProgressToast({
417 kind: 'sessionZip',
418 title: t('sessionDetail.sessionZipPreparing'),
419 description: t('sessionDetail.sessionZipPreparingDescription')
420 })
421 try {
422 const result = await ipc.exportSessionZip(sessionId)
423 if (result.cancelled) {
424 progressToast.cancel(t('sessionDetail.exportCancelled'))
425 return
426 }
427 if (!result.success || !result.path) {
428 progressToast.error(t('sessionDetail.exportFailed'))
429 return
430 }
431 progressToast.success(t('sessionDetail.sessionZipExported'), {
432 description: t('sessionDetail.sessionZipExportedDescription')
433 })
434 } catch (error) {
435 progressToast.error(error instanceof Error ? error.message : t('sessionDetail.exportFailed'))
436 } finally {
437 progressToast.dispose()
438 useSessionDetailUiStore.getState().setIsExportingSessionZip(false)
439 }
440 }
441
442 const exportOutlinesMarkdown = async (): Promise<void> => {
443 if (!sessionId) return
444 try {
445 const result = await ipc.exportOutlinesMarkdown(sessionId)
446 if (result.cancelled) {
447 toastInfo(t('sessionDetail.exportCancelled'))
448 return
449 }
450 if (!result.success || !result.path) {
451 toastError(t('sessionDetail.exportFailed'))
452 return
453 }
454 toastSuccess(t('sessionDetail.outlinesExported'))
455 } catch (error) {
456 toastError(error instanceof Error ? error.message : t('sessionDetail.exportFailed'))
457 }
458 }
459
460 const openProjectPreview = async (): Promise<void> => {
461 const basePath = selectedPage?.htmlPath || pages[0]?.htmlPath
462 if (!basePath) return
463 const indexPath = basePath.replace(/[^/\\]+\.html$/i, 'index.html')
464 const pageHash = selectedPage?.id || pages[0]?.id
465 await ipc.openInBrowser(
466 indexPath,
467 pageHash ? `#${pageHash}` : undefined,
468 sessionId || undefined
469 )
470 }
471
472 const revealSelectedPageFile = async (): Promise<void> => {
473 if (!selectedPage?.htmlPath) return
474 await ipc.revealFile(selectedPage.htmlPath, sessionId || undefined)
475 }
476
477 const openPresentation = async (): Promise<void> => {
478 const idx = pages.findIndex((page) => page.id === selectedPageId)
479 await ipc.openPresentation({
480 sessionId,
481 startIndex: idx >= 0 ? idx : 0
482 })
483 }
484
485 return {
486 exportPdf,
487 exportPng,
488 exportLongImage,
489 exportVideo,
490 exportPptx,
491 canExportPptx,
492 exportSlidePack,
493 exportSessionZip,
494 exportOutlinesMarkdown,
495 openProjectPreview,
496 revealSelectedPageFile,
497 openPresentation
498 }
499 }
500
500 lines TYPESCRIPT