返回 oh-my-ppt
session-detail.tsx
根目录 / src / renderer / src / pages / session-detail.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2 import { useNavigate, useParams } from 'react-router-dom'
3 import { ipc } from '@renderer/lib/ipc'
4 import type { EditableElementSnapshot } from '@arcsin1/presentation-editor-runtime'
5 import type { PreviewIframeHandle } from '../components/preview/PreviewIframe'
6 import { TooltipProvider } from '../components/ui/Tooltip'
7 import { PageSidebar } from '../components/session-detail/sidebar'
8 import { PreviewStage } from '../components/session-detail/preview'
9 import { BrowseView } from '../components/session-detail/browse/BrowseView'
10 import { StyleView } from '../components/session-detail/style/StyleView'
11 import { StyleSwitchJobBar } from '../components/session-detail/style/StyleSwitchJobBar'
12 import { ElementInspectorPanel } from '../components/session-detail/element-inspector'
13 import { SessionDetailRightPanel, WorkspaceRibbon } from '../components/session-detail/workspace'
14 import { SessionToolbar } from '../components/session-detail/toolbar'
15 import {
16 AddBlankPageDialog,
17 AddPageDialog,
18 AssetPickerDialog,
19 DeleteElementDialog,
20 DeletePageDialog,
21 HistoryDialog,
22 MergeSessionPagesDialog,
23 MergeTemplatePagesDialog,
24 PageTitleEditDialog
25 } from '../components/session-detail/modal'
26 import {
27 buildImageMessageCacheKey,
28 imageHistoryToMessages,
29 isDeckEditGenerationEvent,
30 isPageBeautifyGenerationEvent,
31 isPageEditGenerationEvent,
32 isStyleSwitchGenerationEvent,
33 mergeImageMessages,
34 normalizePagesForSelection,
35 type ChatType
36 } from '../components/session-detail/shared'
37 import { useWorkspaceRibbonActionsRegistration } from '../components/session-detail/hooks/useWorkspaceRibbonController'
38 import { buildSelectedElementFromSnapshot } from '../components/session-detail/element-inspector/elementEditUtils'
39 import { renderFormulaToHtml } from '../components/session-detail/element-inspector/formulaEditUtils'
40 import {
41 useEditHistoryStore,
42 useEditSessionStore,
43 useGenerateStore,
44 isStyleSwitchPageLocked,
45 useSessionDetailRuntimeStore,
46 useSessionDetailUiStore,
47 useSessionStore,
48 useToastStore,
49 type AddSessionElementHandler,
50 type AddSessionElementOptions
51 } from '../store'
52 import type { GenerateChunkEvent } from '@shared/generation.js'
53 import { getEditorGate, parseSessionMetadata } from '../lib/sessionMetadata'
54 import { buildArtTextHtmlFragment, type ArtTextTemplateId } from '../lib/artTextTemplates'
55 import {
56 buildIconElementHtml,
57 buildShapeElementHtml,
58 getShapeDefinition,
59 type InsertShapeType
60 } from '../components/session-detail/workspace/insert-shapes'
61 import {
62 buildChartElementHtml,
63 DEFAULT_CHART_DATA,
64 type InsertChartType
65 } from '../components/session-detail/workspace/insert-charts'
66 import { escapeHtmlText } from '../lib/utils'
67 import { useT } from '../i18n'
68 import { nanoid } from 'nanoid'
69 import { requireSessionSlideSize } from '@shared/slide-size'
70
71 const ADDED_ELEMENT_EDGE_PADDING = 20
72 const ADDED_TEXT_WIDTH = 420
73 const ADDED_TEXT_MIN_HEIGHT = 96
74 const ADDED_TEXT_OFFSET_STEP = 28
75 const ADDED_ART_TEXT_WIDTH = 560
76 const ADDED_ART_TEXT_MIN_HEIGHT = 130
77 const ADDED_ICON_SIZE = 96
78 const ADDED_FORMULA_WIDTH = 420
79 const ADDED_FORMULA_HEIGHT = 112
80 const ADDED_CHART_WIDTH = 520
81 const ADDED_CHART_HEIGHT = 300
82 const DEFAULT_FORMULA_LATEX = 'x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}'
83 const ADDED_MEDIA_OFFSET_STEP = 30
84
85 function escapeCssString(value: string): string {
86 return value
87 .replace(/\\/g, '\\\\')
88 .replace(/"/g, '\\"')
89 .replace(/\r?\n/g, ' ')
90 .replace(/</g, '\\3C ')
91 .replace(/>/g, '\\3E ')
92 }
93
94 export function SessionDetailPage(): React.JSX.Element {
95 const { id } = useParams<{ id: string }>()
96 const navigate = useNavigate()
97 const t = useT()
98 const isMac = window.electron?.process?.platform === 'darwin'
99 const {
100 currentSession,
101 currentGeneratedPages,
102 loadSession,
103 loadMessages,
104 setMessages,
105 addMessage,
106 resetRuntimeState
107 } = useSessionStore()
108 const slideSize = currentSession ? requireSessionSlideSize(currentSession) : null
109 const { updateProgress, currentPages } = useGenerateStore()
110 const styleSwitchJob = useGenerateStore((state) =>
111 id ? state.styleSwitchJobs[id] || null : null
112 )
113 const isStyleSwitchActive =
114 styleSwitchJob?.status === 'starting' ||
115 styleSwitchJob?.status === 'running' ||
116 styleSwitchJob?.status === 'cancelling'
117 const chatType = useSessionDetailUiStore((state) => state.chatType)
118 const selectedPageId = useSessionDetailUiStore((state) => state.selectedPageId)
119 const setChatType = useSessionDetailUiStore((state) => state.setChatType)
120 const resetForPageChange = useSessionDetailUiStore((state) => state.resetForPageChange)
121 const resetForSessionChange = useSessionDetailUiStore((state) => state.resetForSessionChange)
122 const clearEditSelectedElement = useSessionDetailUiStore(
123 (state) => state.clearEditSelectedElement
124 )
125 const assetPickerOpen = useSessionDetailUiStore((state) => state.assetPickerOpen)
126 const assetPickerType = useSessionDetailUiStore((state) => state.assetPickerType)
127 const setAssetPickerOpen = useSessionDetailUiStore((state) => state.setAssetPickerOpen)
128 const workspaceTab = useSessionDetailUiStore((state) => state.workspaceTab)
129 const activeChatRef = useRef<{ chatType: ChatType; pageId?: string }>({ chatType: 'page' })
130 const pageEditStateEpochRef = useRef(0)
131 const pageBeautifyStateEpochRef = useRef(0)
132 const deckEditStateEpochRef = useRef(0)
133 // Dedup terminal-run toasts. StrictMode double-invokes effects in dev, and any dep
134 // drift mid-run re-subscribes the generate-chunk handler; both can deliver the same
135 // run_completed/run_error event to a fresh closure. Without this guard the success
136 // toast ("页面美化完成" / "当前页已是最优版本") would fire once per re-subscription.
137 const handledTerminalRunsRef = useRef(new Set<string>())
138 const styleSwitchStateEpochRef = useRef(0)
139 const editHistory = useEditHistoryStore()
140 const isSavingEdits = useEditSessionStore((state) => state.isSavingEdits)
141 const elementSelection = useEditSessionStore((state) => state.selection)
142 const elementDraft = useEditSessionStore((state) => state.draft)
143 const [previewRefreshKey, setPreviewRefreshKey] = useState(0)
144 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
145 const [pendingDeleteSelector, setPendingDeleteSelector] = useState<string | null>(null)
146 const previewIframeRef = useRef<PreviewIframeHandle | null>(null)
147 const addElementHandlerRef = useRef<AddSessionElementHandler | null>(null)
148 const setAddElementHandler = useSessionDetailRuntimeStore((state) => state.setAddElementHandler)
149 const setRefreshCurrentPreviewHandler = useSessionDetailRuntimeStore(
150 (state) => state.setRefreshCurrentPreviewHandler
151 )
152 const setReloadCurrentPreviewIgnoringCacheHandler = useSessionDetailRuntimeStore(
153 (state) => state.setReloadCurrentPreviewIgnoringCacheHandler
154 )
155 const invokeAddElement = useCallback<AddSessionElementHandler>(
156 async (relativePath, fileName, options) => {
157 const handler = addElementHandlerRef.current
158 return handler ? handler(relativePath, fileName, options) : false
159 },
160 []
161 )
162 const toastError = useToastStore((state) => state.error)
163 const toastSuccess = useToastStore((state) => state.success)
164
165 const orderedPages = useMemo(
166 () => [...currentPages].sort((a, b) => a.pageNumber - b.pageNumber),
167 [currentPages]
168 )
169
170 const normalizedOrderedPages = useMemo(
171 () => normalizePagesForSelection(orderedPages),
172 [orderedPages]
173 )
174
175 const selectedPage = useMemo(
176 () =>
177 normalizedOrderedPages.find((page) => page.id === selectedPageId) ??
178 normalizedOrderedPages[0] ??
179 null,
180 [normalizedOrderedPages, selectedPageId]
181 )
182 const selectedPageStyleLocked = isStyleSwitchPageLocked(styleSwitchJob, selectedPage?.pageId)
183
184 const selectedPageRef = useRef(selectedPage)
185 selectedPageRef.current = selectedPage
186 const sessionIdRef = useRef(id)
187 sessionIdRef.current = id
188 const translateRef = useRef(t)
189 translateRef.current = t
190
191 useEffect(() => {
192 useEditSessionStore.getState().attach({
193 t: (key, params) => translateRef.current(key, params),
194 requestRefresh: () => setPreviewRefreshKey((key) => key + 1),
195 bumpThumbnail: (pageId) => useSessionDetailUiStore.getState().bumpThumbnailVersion(pageId),
196 getPageContext: () => {
197 const page = selectedPageRef.current
198 const sessionId = sessionIdRef.current
199 if (!page?.pageId || !page.htmlPath || !sessionId) return null
200 return { pageId: page.pageId, htmlPath: page.htmlPath, sessionId }
201 }
202 })
203 }, [])
204
205 useEffect(() => {
206 setRefreshCurrentPreviewHandler(() => {
207 const selected = selectedPageRef.current
208 if (!selected?.pageId) return
209 setPreviewRefreshKey((key) => key + 1)
210 })
211 return () => setRefreshCurrentPreviewHandler(null)
212 }, [setRefreshCurrentPreviewHandler])
213
214 useEffect(() => {
215 setReloadCurrentPreviewIgnoringCacheHandler(() => {
216 previewIframeRef.current?.reloadIgnoringCache()
217 })
218 return () => setReloadCurrentPreviewIgnoringCacheHandler(null)
219 }, [setReloadCurrentPreviewIgnoringCacheHandler])
220
221 const handlePreviewIframe = useCallback((handle: PreviewIframeHandle | null): void => {
222 previewIframeRef.current = handle
223 useEditSessionStore.getState().setIframeHandle(handle)
224 }, [])
225
226 useEffect(() => {
227 resetForPageChange()
228 useEditSessionStore.getState().resetForPage()
229 clearEditSelectedElement()
230 }, [clearEditSelectedElement, resetForPageChange, selectedPage?.pageId])
231
232 useEffect(() => {
233 if (!selectedPageStyleLocked) return
234 useSessionDetailUiStore.getState().setInteractionMode('preview')
235 useSessionDetailUiStore.getState().clearEditSelectedElement()
236 useEditSessionStore.getState().cancelEdit()
237 }, [selectedPageStyleLocked])
238
239 const canEditInSessionDetail = useMemo(() => {
240 if (!currentSession) return false
241 return getEditorGate(currentSession).canEdit
242 }, [currentSession])
243 useEffect(() => {
244 if (!id) return
245 let cancelled = false
246 setMessages([])
247 useGenerateStore.getState().setPages([])
248 resetForSessionChange()
249 void (async () => {
250 try {
251 await ipc.migratePageOutlinesToSourceSkeletons({ sessionId: id })
252 } catch (err) {
253 console.warn('[session] migrate page outlines failed', err)
254 }
255 if (!cancelled) {
256 await loadSession(id, () => !cancelled)
257 }
258 })()
259 // Cleanup on unmount (leaving session-detail)
260 return () => {
261 cancelled = true
262 useGenerateStore.getState().reset()
263 useSessionDetailUiStore.getState().resetForSessionChange()
264 useEditHistoryStore.getState().clear()
265 useEditSessionStore.getState().resetForPage()
266 }
267 }, [id, loadSession, resetForSessionChange, setMessages])
268
269 useEffect(() => {
270 useGenerateStore.getState().setPages(currentGeneratedPages)
271 }, [currentGeneratedPages])
272
273 useEffect(() => {
274 if (!id) return
275 let disposed = false
276 void ipc
277 .getGenerateState(id)
278 .then((state) => {
279 if (disposed || !state.hasActiveRun) return
280 const ui = useSessionDetailUiStore.getState()
281 if (state.activityKind === 'addPage') {
282 ui.setIsAddingPage(true)
283 ui.setAddingPageId(state.targetPageId || null)
284 if (state.targetPageId) ui.setSelectedPageId(state.targetPageId)
285 } else if (state.activityKind === 'single-page-retry') {
286 ui.setIsRetryingSinglePage(true)
287 ui.setRetryingSinglePageId(state.targetPageId || null)
288 } else {
289 return
290 }
291 useGenerateStore.setState({ isGenerating: true, error: null, status: 'running' })
292 })
293 .catch(() => {})
294 return () => {
295 disposed = true
296 }
297 }, [id])
298
299 useEffect(() => {
300 if (!id || !currentSession) return
301 // Don't redirect during addPage / retrySinglePage — we're already on the editor page
302 if (
303 useSessionDetailUiStore.getState().isAddingPage ||
304 useSessionDetailUiStore.getState().isRetryingSinglePage
305 )
306 return
307 if (canEditInSessionDetail || isStyleSwitchActive) return
308 let disposed = false
309 const redirectToGeneration = (): void => {
310 if (disposed) return
311 const metadata = parseSessionMetadata(currentSession.metadata)
312 navigate(
313 metadata.source === 'template'
314 ? `/sessions/${id}/template-generating`
315 : `/sessions/${id}/generating`,
316 { replace: true }
317 )
318 }
319 void ipc
320 .getStyleSwitchState(id)
321 .then((state) => {
322 if (!state.hasActiveRun) redirectToGeneration()
323 })
324 .catch(redirectToGeneration)
325 return () => {
326 disposed = true
327 }
328 }, [canEditInSessionDetail, currentSession, id, isStyleSwitchActive, navigate])
329
330 useEffect(() => {
331 if (!id) return
332 const saved = window.localStorage.getItem(`workbench:selected-page-id:${id}`)
333 if (!saved) return
334 useSessionDetailUiStore.getState().setSelectedPageId(saved)
335 }, [id])
336
337 useEffect(() => {
338 if (!id) return
339 let disposed = false
340 const requestEpoch = pageBeautifyStateEpochRef.current
341 void ipc
342 .getPageBeautifyState(id)
343 .then((state) => {
344 if (
345 disposed ||
346 requestEpoch !== pageBeautifyStateEpochRef.current ||
347 !state.hasActiveRun ||
348 state.kind !== 'page-beautify' ||
349 !state.targetPageId
350 )
351 return
352 const generateState = useGenerateStore.getState()
353 if (generateState.pageBeautifyJobs[id]) return
354 generateState.startPageBeautify(id, {
355 pageId: state.targetPageId,
356 pageNumber: state.targetPageNumber
357 })
358 generateState.updatePageBeautify(id, {
359 runId: state.runId || undefined,
360 status: state.status === 'queued' ? 'queued' : 'running',
361 progress: state.progress
362 })
363 })
364 .catch(() => {})
365 return () => {
366 disposed = true
367 }
368 }, [id])
369
370 useEffect(() => {
371 if (!id) return
372 let disposed = false
373 const requestEpoch = styleSwitchStateEpochRef.current
374 void ipc
375 .getStyleSwitchState(id)
376 .then((state) => {
377 if (
378 disposed ||
379 requestEpoch !== styleSwitchStateEpochRef.current ||
380 state.status === 'idle'
381 ) {
382 return
383 }
384 useGenerateStore.getState().setStyleSwitchJob(id, {
385 sessionId: id,
386 runId: state.runId || undefined,
387 styleId: state.targetStyleId || '',
388 styleName: state.targetStyleName || undefined,
389 status: state.status,
390 progress: state.progress,
391 totalPages: state.totalPages,
392 error: state.error,
393 pages: state.pages
394 })
395 })
396 .catch(() => {})
397 return () => {
398 disposed = true
399 }
400 }, [id])
401
402 useEffect(() => {
403 if (!id) return
404 let disposed = false
405 const requestEpoch = deckEditStateEpochRef.current
406 void ipc
407 .getDeckEditState(id)
408 .then((state) => {
409 if (
410 disposed ||
411 requestEpoch !== deckEditStateEpochRef.current ||
412 state.kind !== 'deck-edit'
413 )
414 return
415 const generateState = useGenerateStore.getState()
416 if (state.hasActiveRun) {
417 if (generateState.deckEditJobs[id]) return
418 generateState.startDeckEdit(id, {
419 totalPages: state.totalPages,
420 payload: state.retryPayload
421 })
422 generateState.updateDeckEdit(id, {
423 runId: state.runId || undefined,
424 status: state.status === 'queued' ? 'queued' : 'running',
425 progress: state.progress
426 })
427 return
428 }
429 if (
430 state.runId &&
431 state.retryPayload &&
432 Math.max(0, Number(state.failedPageCount) || 0) > 0
433 ) {
434 generateState.finishDeckEdit(id, {
435 runId: state.runId,
436 failedPageCount: Math.max(1, Number(state.failedPageCount) || 1),
437 payload: state.retryPayload
438 })
439 }
440 })
441 .catch(() => {})
442 return () => {
443 disposed = true
444 }
445 }, [id])
446
447 useEffect(() => {
448 if (!id) return
449 let disposed = false
450 const requestEpoch = pageEditStateEpochRef.current
451 void ipc
452 .getPageEditState(id)
453 .then((state) => {
454 if (
455 disposed ||
456 requestEpoch !== pageEditStateEpochRef.current ||
457 !state.hasActiveRun ||
458 state.kind !== 'page-edit' ||
459 !state.targetPageId
460 )
461 return
462 const generateState = useGenerateStore.getState()
463 if (generateState.pageEditJobs[id]) return
464 generateState.startPageEdit(id, {
465 pageId: state.targetPageId,
466 pageNumber: state.targetPageNumber
467 })
468 generateState.updatePageEdit(id, {
469 runId: state.runId || undefined,
470 status: state.status === 'queued' ? 'queued' : 'running',
471 progress: state.progress
472 })
473 })
474 .catch(() => {})
475 return () => {
476 disposed = true
477 }
478 }, [id])
479
480 useEffect(() => {
481 // Skip auto-select during addPage / retrySinglePage — selection managed explicitly
482 if (
483 useSessionDetailUiStore.getState().isAddingPage ||
484 useSessionDetailUiStore.getState().isRetryingSinglePage
485 )
486 return
487
488 if (normalizedOrderedPages.length === 0) {
489 useSessionDetailUiStore.getState().setSelectedPageId(null)
490 return
491 }
492
493 if (selectedPageId && normalizedOrderedPages.some((page) => page.id === selectedPageId)) {
494 return
495 }
496
497 useSessionDetailUiStore.getState().setSelectedPageId(normalizedOrderedPages[0].id)
498 }, [normalizedOrderedPages, selectedPageId])
499
500 useEffect(() => {
501 if (!id || !selectedPageId) return
502 window.localStorage.setItem(`workbench:selected-page-id:${id}`, String(selectedPageId))
503 }, [id, selectedPageId])
504
505 useEffect(() => {
506 setChatType('page')
507 }, [id, setChatType])
508
509 useEffect(() => {
510 const pageId = chatType === 'page' ? selectedPage?.id : undefined
511 activeChatRef.current = { chatType, pageId }
512 }, [chatType, selectedPage?.id])
513
514 useEffect(() => {
515 if (!id) return
516 if (chatType === 'page' && !selectedPage?.id) {
517 void loadMessages({
518 sessionId: id,
519 chatType: 'page',
520 pageId: undefined
521 })
522 return
523 }
524 void loadMessages({
525 sessionId: id,
526 chatType,
527 pageId: chatType === 'page' ? selectedPage?.id : undefined
528 })
529 }, [id, chatType, selectedPage?.id, loadMessages, setMessages])
530
531 useEffect(() => {
532 const pageId = selectedPage?.id
533 if (!id || !pageId) {
534 useSessionDetailUiStore.getState().setImageMessages([])
535 return
536 }
537
538 const cacheKey = buildImageMessageCacheKey(id, pageId)
539 const detailState = useSessionDetailUiStore.getState()
540 if (detailState.loadedImageMessageKeys[cacheKey]) {
541 detailState.setImageMessages(detailState.imageMessageCache[cacheKey] || [])
542 return
543 }
544
545 detailState.setImageMessages(detailState.imageMessageCache[cacheKey] || [])
546 let cancelled = false
547 void ipc
548 .listImageGenerationHistory({ sessionId: id, pageId })
549 .then((histories) => {
550 if (cancelled) return
551 const historyMessages = imageHistoryToMessages(histories)
552 const latestState = useSessionDetailUiStore.getState()
553 const mergedMessages = mergeImageMessages(
554 historyMessages,
555 latestState.imageMessageCache[cacheKey] || []
556 )
557 latestState.setLoadedImageMessages(cacheKey, mergedMessages)
558 latestState.setImageMessages(mergedMessages)
559 })
560 .catch((err) => {
561 if (!cancelled) {
562 toastError(err instanceof Error ? err.message : t('sessionDetail.imageHistoryLoadFailed'))
563 }
564 })
565
566 return () => {
567 cancelled = true
568 }
569 }, [id, selectedPage?.id, t, toastError])
570
571 useEffect(() => {
572 if (!id) return
573 const handler = (event: GenerateChunkEvent): void => {
574 const { type, payload } = event
575 if (payload.sessionId && payload.sessionId !== id) return
576 const activePageEditJob = useGenerateStore.getState().pageEditJobs[id] || null
577 const activePageBeautifyJob = useGenerateStore.getState().pageBeautifyJobs[id] || null
578 const activeDeckEditJob = useGenerateStore.getState().deckEditJobs[id] || null
579 const activeStyleSwitchJob = useGenerateStore.getState().styleSwitchJobs[id] || null
580 const isPageEdit = isPageEditGenerationEvent(payload, activePageEditJob)
581 const isPageBeautify = isPageBeautifyGenerationEvent(payload, activePageBeautifyJob)
582 const isDeckEdit = isDeckEditGenerationEvent(payload, activeDeckEditJob)
583 const isStyleSwitch = isStyleSwitchGenerationEvent(payload, activeStyleSwitchJob)
584 const isAddingPageRun =
585 payload.activityKind === 'addPage' && useSessionDetailUiStore.getState().isAddingPage
586 const isRetryingSinglePageRun =
587 payload.activityKind === 'single-page-retry' &&
588 useSessionDetailUiStore.getState().isRetryingSinglePage
589 if (
590 type === 'stage_started' ||
591 type === 'stage_progress' ||
592 type === 'page_generated' ||
593 type === 'page_started' ||
594 type === 'llm_status'
595 ) {
596 if (isPageEdit) {
597 useGenerateStore.getState().updatePageEdit(id, {
598 runId: payload.runId,
599 status:
600 activePageEditJob?.status === 'cancelling'
601 ? 'cancelling'
602 : payload.stage === 'queued'
603 ? 'queued'
604 : 'running',
605 label: payload.label,
606 progress: payload.progress ?? 0
607 })
608 } else if (isPageBeautify) {
609 useGenerateStore.getState().updatePageBeautify(id, {
610 runId: payload.runId,
611 status:
612 activePageBeautifyJob?.status === 'cancelling'
613 ? 'cancelling'
614 : payload.stage === 'queued'
615 ? 'queued'
616 : 'running',
617 label: payload.label,
618 progress: payload.progress ?? 0
619 })
620 } else if (isDeckEdit) {
621 useGenerateStore.getState().updateDeckEdit(id, {
622 runId: payload.runId,
623 status:
624 activeDeckEditJob?.status === 'cancelling'
625 ? 'cancelling'
626 : payload.stage === 'queued'
627 ? 'queued'
628 : 'running',
629 label: payload.label,
630 progress: payload.progress ?? 0,
631 totalPages: payload.totalPages
632 })
633 } else if (isStyleSwitch) {
634 useGenerateStore.getState().updateStyleSwitchJob(id, {
635 runId: payload.runId,
636 status: activeStyleSwitchJob?.status === 'cancelling' ? 'cancelling' : 'running',
637 progress: payload.progress ?? activeStyleSwitchJob?.progress ?? 0,
638 totalPages: payload.totalPages || activeStyleSwitchJob?.totalPages || 1
639 })
640 if (type === 'page_started' && payload.pageId) {
641 useGenerateStore.getState().updateStyleSwitchPage(id, payload.pageId, {
642 status: 'running',
643 error: null
644 })
645 }
646 } else {
647 // 不清空 currentPages,保持预览可见
648 useGenerateStore.getState().clearSessionError(id)
649 useGenerateStore.setState({ isGenerating: true, error: null, status: 'running' })
650 updateProgress({
651 stage: payload.stage,
652 label: payload.label,
653 progress: payload.progress ?? 0,
654 currentPage: payload.currentPage,
655 totalPages: payload.totalPages
656 })
657 }
658 if (type === 'page_generated') {
659 // Skip page_generated during addPage — pages will be reloaded on run_completed
660 if (useSessionDetailUiStore.getState().isAddingPage) {
661 updateProgress({
662 stage: payload.stage,
663 label: payload.label,
664 progress: payload.progress ?? 0,
665 currentPage: payload.currentPage,
666 totalPages: payload.totalPages
667 })
668 return
669 }
670 const store = useGenerateStore.getState()
671 const existingPage = store.currentPages.find((page) =>
672 payload.id
673 ? page.id === payload.id
674 : payload.pageId
675 ? page.pageId === payload.pageId
676 : page.pageNumber === payload.pageNumber
677 )
678 const entityId =
679 payload.id || existingPage?.id || payload.pageId || `page-${payload.pageNumber}`
680 // 全新生成:第 1 页到来时清掉旧页面,避免新旧混合
681 if (payload.pageNumber === 1 && store.currentPages.length > 0) {
682 store.setPages([])
683 }
684 store.addPage({
685 id: entityId,
686 pageNumber: payload.pageNumber,
687 title: payload.title,
688 contentOutline: payload.contentOutline,
689 html: payload.html,
690 htmlPath: payload.htmlPath,
691 pageId: payload.pageId || `page-${payload.pageNumber}`,
692 sourceUrl: payload.sourceUrl,
693 status: 'completed',
694 error: null
695 })
696 if (payload.focusPage !== false) {
697 useSessionDetailUiStore.getState().setSelectedPageId(entityId)
698 }
699 useSessionDetailUiStore.getState().bumpPreviewKey()
700 }
701 } else if (type === 'page_updated') {
702 if (isPageEdit) {
703 useGenerateStore.getState().updatePageEdit(id, {
704 runId: payload.runId,
705 status: activePageEditJob?.status === 'cancelling' ? 'cancelling' : 'running',
706 label: payload.label,
707 progress: payload.progress ?? 0
708 })
709 } else if (isPageBeautify) {
710 useGenerateStore.getState().updatePageBeautify(id, {
711 runId: payload.runId,
712 status: activePageBeautifyJob?.status === 'cancelling' ? 'cancelling' : 'running',
713 label: payload.label,
714 progress: payload.progress ?? 0
715 })
716 } else if (isDeckEdit) {
717 useGenerateStore.getState().updateDeckEdit(id, {
718 runId: payload.runId,
719 status: activeDeckEditJob?.status === 'cancelling' ? 'cancelling' : 'running',
720 label: payload.label,
721 progress: payload.progress ?? 0,
722 totalPages: payload.totalPages
723 })
724 } else if (isStyleSwitch) {
725 useGenerateStore.getState().updateStyleSwitchJob(id, {
726 runId: payload.runId,
727 status: activeStyleSwitchJob?.status === 'cancelling' ? 'cancelling' : 'running',
728 progress: payload.progress ?? activeStyleSwitchJob?.progress ?? 0,
729 totalPages: payload.totalPages || activeStyleSwitchJob?.totalPages || 1
730 })
731 if (payload.pageId) {
732 useGenerateStore.getState().updateStyleSwitchPage(id, payload.pageId, {
733 status: 'completed',
734 error: null
735 })
736 }
737 } else {
738 useGenerateStore.getState().clearSessionError(id)
739 useGenerateStore.setState({ isGenerating: true, error: null, status: 'running' })
740 }
741 const store = useGenerateStore.getState()
742 const existingPage = store.currentPages.find((page) =>
743 payload.id
744 ? page.id === payload.id
745 : payload.pageId
746 ? page.pageId === payload.pageId
747 : page.pageNumber === payload.pageNumber
748 )
749 const entityId =
750 payload.id || existingPage?.id || payload.pageId || `page-${payload.pageNumber}`
751 useGenerateStore.getState().addPage({
752 id: entityId,
753 pageNumber: payload.pageNumber,
754 title: payload.title,
755 contentOutline: payload.contentOutline,
756 html: payload.html,
757 htmlPath: payload.htmlPath,
758 pageId: payload.pageId || `page-${payload.pageNumber}`,
759 sourceUrl: payload.sourceUrl,
760 status: 'completed',
761 error: null
762 })
763 if (
764 !isPageEdit &&
765 !isPageBeautify &&
766 !isDeckEdit &&
767 !isStyleSwitch &&
768 payload.focusPage !== false
769 ) {
770 useSessionDetailUiStore.getState().setSelectedPageId(entityId)
771 }
772 useSessionDetailUiStore.getState().bumpPreviewKey()
773 } else if (type === 'page_failed' && isStyleSwitch) {
774 if (payload.pageId) {
775 useGenerateStore.getState().updateStyleSwitchPage(id, payload.pageId, {
776 status: 'failed',
777 error: payload.error || '页面切换失败'
778 })
779 const store = useGenerateStore.getState()
780 const page = store.currentPages.find((item) => item.pageId === payload.pageId)
781 if (page) {
782 store.addPage({ ...page, status: 'failed', error: payload.error || '页面切换失败' })
783 }
784 }
785 } else if (type === 'assistant_message') {
786 const incomingType = payload.chatType === 'page' && payload.pageId ? 'page' : 'main'
787 const incomingPageId = incomingType === 'page' ? payload.pageId : undefined
788 const active = activeChatRef.current
789 const matchesCurrentChat =
790 incomingType === active.chatType &&
791 (incomingType !== 'page' || incomingPageId === active.pageId)
792 if (!matchesCurrentChat) return
793 const createdAt = payload.timestamp
794 ? Math.floor(new Date(payload.timestamp).getTime() / 1000)
795 : Math.floor(Date.now() / 1000)
796 addMessage({
797 id: payload.id || crypto.randomUUID(),
798 session_id: id,
799 chat_scope: incomingType,
800 page_id: incomingPageId || null,
801 role: 'assistant',
802 content: payload.content,
803 type: 'text',
804 tool_name: null,
805 tool_call_id: null,
806 token_count: null,
807 created_at: Number.isFinite(createdAt) ? createdAt : Math.floor(Date.now() / 1000)
808 })
809 } else if (type === 'run_completed') {
810 const terminalKey = `${payload.runId}:completed`
811 if (payload.runId && handledTerminalRunsRef.current.has(terminalKey)) return
812 if (payload.runId) {
813 handledTerminalRunsRef.current.add(terminalKey)
814 if (handledTerminalRunsRef.current.size > 100) {
815 const oldest = handledTerminalRunsRef.current.values().next().value
816 if (typeof oldest === 'string') handledTerminalRunsRef.current.delete(oldest)
817 }
818 }
819 if (isPageEdit) {
820 pageEditStateEpochRef.current += 1
821 useGenerateStore.getState().finishPageEdit(id)
822 } else if (isPageBeautify) {
823 pageBeautifyStateEpochRef.current += 1
824 useGenerateStore.getState().finishPageBeautify(id)
825 toastSuccess(
826 payload.outcome === 'unchanged'
827 ? t('sessionDetail.pageBeautifyUnchanged')
828 : t('sessionDetail.pageBeautifyCompleted')
829 )
830 void loadSession(id)
831 } else if (isDeckEdit) {
832 deckEditStateEpochRef.current += 1
833 const retryPayload = activeDeckEditJob?.payload
834 const failedPageCount = Math.max(0, Number(payload.failedPageCount) || 0)
835 useGenerateStore
836 .getState()
837 .finishDeckEdit(
838 id,
839 retryPayload && failedPageCount > 0
840 ? { runId: payload.runId, failedPageCount, payload: retryPayload }
841 : undefined
842 )
843 void loadSession(id)
844 } else if (isStyleSwitch) {
845 styleSwitchStateEpochRef.current += 1
846 const failedPageCount = Math.max(0, Number(payload.failedPageCount) || 0)
847 useGenerateStore.getState().finishStyleSwitch(id, {
848 status: failedPageCount > 0 ? 'partial' : 'completed',
849 error: failedPageCount > 0 ? activeStyleSwitchJob?.error || null : null
850 })
851 void loadSession(id)
852 } else if (isAddingPageRun) {
853 const selectedPageId = useSessionDetailUiStore.getState().selectedPageId
854 void loadSession(id)
855 .then(() => {
856 useGenerateStore.getState().setPages(useSessionStore.getState().currentGeneratedPages)
857 })
858 .catch((error) => console.warn('[session-detail] reload added page failed', error))
859 .finally(() => {
860 useSessionDetailUiStore.getState().finishAddPage(selectedPageId)
861 useGenerateStore.getState().finishGeneration()
862 })
863 } else if (isRetryingSinglePageRun) {
864 void loadSession(id)
865 .then(() => {
866 useGenerateStore.getState().setPages(useSessionStore.getState().currentGeneratedPages)
867 })
868 .catch((error) => console.warn('[session-detail] reload retried page failed', error))
869 .finally(() => {
870 useSessionDetailUiStore.getState().setIsRetryingSinglePage(false)
871 useGenerateStore.getState().finishGeneration()
872 })
873 } else if (!useSessionDetailUiStore.getState().isAddingPage) {
874 useGenerateStore.getState().finishGeneration()
875 }
876 } else if (type === 'run_error') {
877 const terminalKey = `${payload.runId}:error`
878 if (payload.runId && handledTerminalRunsRef.current.has(terminalKey)) return
879 if (payload.runId) {
880 handledTerminalRunsRef.current.add(terminalKey)
881 if (handledTerminalRunsRef.current.size > 100) {
882 const oldest = handledTerminalRunsRef.current.values().next().value
883 if (typeof oldest === 'string') handledTerminalRunsRef.current.delete(oldest)
884 }
885 }
886 if (isPageEdit) {
887 pageEditStateEpochRef.current += 1
888 useGenerateStore.getState().finishPageEdit(id)
889 if (!payload.cancelled) {
890 useGenerateStore.getState().setSessionError(id, payload.message)
891 }
892 void loadSession(id)
893 } else if (isPageBeautify) {
894 pageBeautifyStateEpochRef.current += 1
895 useGenerateStore.getState().finishPageBeautify(id)
896 if (!payload.cancelled) {
897 useGenerateStore.getState().setSessionError(id, payload.message)
898 toastError(payload.message || t('sessionDetail.pageBeautifyFailed'))
899 }
900 void loadSession(id)
901 } else if (isDeckEdit) {
902 deckEditStateEpochRef.current += 1
903 const retryPayload = activeDeckEditJob?.payload
904 const failedPageCount = Math.max(0, Number(payload.failedPageCount) || 0)
905 const retryPageCount = failedPageCount || activeDeckEditJob?.totalPages || 1
906 useGenerateStore
907 .getState()
908 .finishDeckEdit(
909 id,
910 !payload.cancelled && retryPayload
911 ? { runId: payload.runId, failedPageCount: retryPageCount, payload: retryPayload }
912 : undefined
913 )
914 if (!payload.cancelled) {
915 useGenerateStore.getState().setSessionError(id, payload.message)
916 }
917 void loadSession(id)
918 } else if (isStyleSwitch) {
919 styleSwitchStateEpochRef.current += 1
920 const failedPageCount = Math.max(0, Number(payload.failedPageCount) || 0)
921 const status = payload.cancelled
922 ? 'cancelled'
923 : failedPageCount > 0 ||
924 activeStyleSwitchJob?.pages.some((page) => page.status === 'completed')
925 ? 'partial'
926 : 'failed'
927 useGenerateStore.getState().finishStyleSwitch(id, { status, error: payload.message })
928 if (!payload.cancelled) useGenerateStore.getState().setSessionError(id, payload.message)
929 void loadSession(id)
930 } else if (isAddingPageRun) {
931 const selectedPageId = useSessionDetailUiStore.getState().selectedPageId
932 void loadSession(id)
933 .then(() => {
934 useGenerateStore.getState().setPages(useSessionStore.getState().currentGeneratedPages)
935 })
936 .catch((error) =>
937 console.warn('[session-detail] reload failed added page failed', error)
938 )
939 .finally(() => {
940 useSessionDetailUiStore.getState().finishAddPage(selectedPageId)
941 useGenerateStore.getState().finishGeneration()
942 })
943 } else if (isRetryingSinglePageRun) {
944 void loadSession(id)
945 .then(() => {
946 useGenerateStore.getState().setPages(useSessionStore.getState().currentGeneratedPages)
947 })
948 .catch((error) =>
949 console.warn('[session-detail] reload failed retried page failed', error)
950 )
951 .finally(() => {
952 useSessionDetailUiStore.getState().setIsRetryingSinglePage(false)
953 useGenerateStore.getState().finishGeneration()
954 })
955 } else if (!useSessionDetailUiStore.getState().isAddingPage) {
956 if (payload.cancelled) {
957 useGenerateStore.getState().cancelGeneration(payload.message)
958 } else {
959 useGenerateStore.getState().setSessionError(id, payload.message)
960 useGenerateStore.setState({ status: 'failed', isGenerating: false, progress: null })
961 }
962 void loadSession(id)
963 }
964 }
965 }
966 const unsubscribe = ipc.onGenerateChunk(handler)
967 return () => {
968 unsubscribe?.()
969 }
970 }, [addMessage, id, t, toastError, toastSuccess, updateProgress])
971
972 useEffect(() => {
973 if (!id) return
974 const unsubscribe = ipc.onSpeechProgress((payload) => {
975 if (payload.sessionId !== id) return
976 useSessionDetailUiStore
977 .getState()
978 .setSpeechProgress({ current: payload.current, total: payload.total })
979 })
980 return () => unsubscribe()
981 }, [id])
982
983 const handleCopyElement = async (): Promise<void> => {
984 if (!elementSelection || !selectedPage?.pageId || !selectedPage.htmlPath) return
985 const blockId = 'select-arcsin1-' + nanoid(8)
986 let copyResult: { selector: string; htmlFragment: string } | null | undefined
987 try {
988 copyResult = await previewIframeRef.current?.copyElement(elementSelection.selector, blockId)
989 } catch (error) {
990 toastError(error instanceof Error ? error.message : t('sessionDetail.copyElementFailed'))
991 return
992 }
993 if (!copyResult) {
994 toastError(t('sessionDetail.copyElementFailed'))
995 return
996 }
997 const newSelector = copyResult.selector
998 const bounds = elementSelection.pageBounds || elementSelection.bounds
999 const zValue =
1000 elementSelection.zIndex !== undefined ? String(elementSelection.zIndex + 1) : '10'
1001 const nextSnapshot = elementSelection.snapshot
1002 ? {
1003 ...elementSelection.snapshot,
1004 selector: newSelector,
1005 blockId,
1006 label: newSelector,
1007 metrics: {
1008 ...elementSelection.snapshot.metrics,
1009 page: bounds
1010 ? { x: bounds.x + 20, y: bounds.y + 20, width: bounds.width, height: bounds.height }
1011 : elementSelection.snapshot.metrics.page,
1012 viewport: bounds
1013 ? { x: bounds.x + 20, y: bounds.y + 20, width: bounds.width, height: bounds.height }
1014 : elementSelection.snapshot.metrics.viewport,
1015 translateX: 0,
1016 translateY: 0
1017 }
1018 }
1019 : null
1020 editHistory.addElement({
1021 pageId: selectedPage.pageId,
1022 htmlPath: selectedPage.htmlPath,
1023 parentSelector: `body[data-page-id="${selectedPage.pageId}"] [data-ppt-guard-root="1"]`,
1024 htmlFragment: copyResult.htmlFragment,
1025 assignedBlockId: blockId,
1026 insertIndex: -1
1027 })
1028 useEditSessionStore.getState().selectElement({
1029 selector: newSelector,
1030 blockId,
1031 label: newSelector,
1032 elementTag: elementSelection.elementTag,
1033 elementText: '',
1034 kind: elementSelection.kind,
1035 capabilities: elementSelection.capabilities,
1036 snapshot: nextSnapshot,
1037 isText: false,
1038 text: '',
1039 style: {},
1040 bounds: bounds
1041 ? { x: bounds.x + 20, y: bounds.y + 20, width: bounds.width, height: bounds.height }
1042 : undefined,
1043 pageBounds: bounds
1044 ? { x: bounds.x + 20, y: bounds.y + 20, width: bounds.width, height: bounds.height }
1045 : undefined,
1046 translateX: 0,
1047 translateY: 0,
1048 zIndex: parseInt(zValue, 10),
1049 editability: { x: true, y: true, width: true, height: true }
1050 })
1051 }
1052
1053 const readElementSnapshotWithRetry = async (
1054 selector: string
1055 ): Promise<EditableElementSnapshot | null> => {
1056 for (let attempt = 0; attempt < 8; attempt += 1) {
1057 if (attempt > 0) {
1058 await new Promise<void>((resolve) => window.setTimeout(resolve, 50))
1059 }
1060 const snapshot = await previewIframeRef.current?.readElementSnapshot(selector)
1061 if (snapshot) return snapshot
1062 }
1063 return null
1064 }
1065
1066 const handleAddTextElement = async (): Promise<void> => {
1067 if (!id || !selectedPage?.pageId || !selectedPage.htmlPath || !slideSize) return
1068 const blockId = 'select-arcsin1-' + nanoid(8)
1069 const parentSelector = `body[data-page-id="${selectedPage.pageId}"] [data-ppt-guard-root="1"]`
1070 const existingCount = editHistory.addElements.filter(
1071 (e) => e.pageId === selectedPage.pageId
1072 ).length
1073 const offset = existingCount * ADDED_TEXT_OFFSET_STEP
1074 const w = ADDED_TEXT_WIDTH
1075 const h = ADDED_TEXT_MIN_HEIGHT
1076 const left = Math.min(
1077 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize.width - w) / 2) + offset,
1078 slideSize.width - w - ADDED_ELEMENT_EDGE_PADDING
1079 )
1080 const top = Math.min(
1081 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize.height - h) / 2) + offset,
1082 slideSize.height - h - ADDED_ELEMENT_EDGE_PADDING
1083 )
1084 const zIdx = 10 + existingCount
1085 const defaultText = t('editMode.defaultText')
1086 const textStyle = [
1087 'position:absolute',
1088 `left:${left}px`,
1089 `top:${top}px`,
1090 `width:${w}px`,
1091 `min-height:${h}px`,
1092 'margin:0',
1093 'padding:0',
1094 `z-index:${zIdx}`,
1095 'color:#34402c',
1096 'font-size:40px',
1097 'font-weight:700',
1098 'line-height:1.18',
1099 'letter-spacing:0',
1100 'white-space:pre-wrap',
1101 'overflow-wrap:anywhere',
1102 'font-family:inherit'
1103 ].join('; ')
1104 const htmlFragment = `<p data-block-id="${blockId}" style="${textStyle};">${escapeHtmlText(defaultText)}</p>`
1105
1106 useEditSessionStore.getState().commitCurrentDraft()
1107 editHistory.addElement({
1108 pageId: selectedPage.pageId,
1109 htmlPath: selectedPage.htmlPath,
1110 parentSelector,
1111 htmlFragment,
1112 assignedBlockId: blockId,
1113 insertIndex: -1
1114 })
1115 previewIframeRef.current?.injectElement(parentSelector, htmlFragment)
1116
1117 const selector = `body[data-page-id="${selectedPage.pageId}"] [data-block-id="${blockId}"]`
1118 if (useSessionDetailUiStore.getState().selectedPageId !== selectedPage.id) return
1119 const snapshot = await readElementSnapshotWithRetry(selector)
1120 if (!snapshot) return
1121 useEditSessionStore.getState().selectElement(
1122 buildSelectedElementFromSnapshot({
1123 selector,
1124 blockId,
1125 snapshot
1126 })
1127 )
1128 }
1129
1130 const handleAddArtTextElement = async (templateId: ArtTextTemplateId): Promise<void> => {
1131 if (!id || !selectedPage?.pageId || !selectedPage.htmlPath || !slideSize) return
1132 const blockId = 'select-arcsin1-' + nanoid(8)
1133 const parentSelector = `body[data-page-id="${selectedPage.pageId}"] [data-ppt-guard-root="1"]`
1134 const existingCount = editHistory.addElements.filter(
1135 (e) => e.pageId === selectedPage.pageId
1136 ).length
1137 const offset = existingCount * ADDED_TEXT_OFFSET_STEP
1138 const w = ADDED_ART_TEXT_WIDTH
1139 const h = ADDED_ART_TEXT_MIN_HEIGHT
1140 const left = Math.min(
1141 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize.width - w) / 2) + offset,
1142 slideSize.width - w - ADDED_ELEMENT_EDGE_PADDING
1143 )
1144 const top = Math.min(
1145 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize.height - h) / 2) + offset,
1146 slideSize.height - h - ADDED_ELEMENT_EDGE_PADDING
1147 )
1148 const zIdx = 10 + existingCount
1149 const htmlFragment = buildArtTextHtmlFragment(templateId, {
1150 blockId,
1151 left,
1152 top,
1153 width: w,
1154 minHeight: h,
1155 zIndex: zIdx
1156 })
1157
1158 useEditSessionStore.getState().commitCurrentDraft()
1159 editHistory.addElement({
1160 pageId: selectedPage.pageId,
1161 htmlPath: selectedPage.htmlPath,
1162 parentSelector,
1163 htmlFragment,
1164 assignedBlockId: blockId,
1165 insertIndex: -1
1166 })
1167 previewIframeRef.current?.injectElement(parentSelector, htmlFragment)
1168
1169 const selector = `body[data-page-id="${selectedPage.pageId}"] [data-block-id="${blockId}"]`
1170 if (useSessionDetailUiStore.getState().selectedPageId !== selectedPage.id) return
1171 const snapshot = await readElementSnapshotWithRetry(selector)
1172 if (!snapshot) return
1173 useEditSessionStore.getState().selectElement(
1174 buildSelectedElementFromSnapshot({
1175 selector,
1176 blockId,
1177 snapshot
1178 })
1179 )
1180 }
1181
1182 const handleAddShapeElement = async (type: InsertShapeType): Promise<void> => {
1183 if (!id || !selectedPage?.pageId || !selectedPage.htmlPath) return
1184 const def = getShapeDefinition(type)
1185 if (!def) return
1186 const blockId = 'select-arcsin1-' + nanoid(8)
1187 const parentSelector = `body[data-page-id="${selectedPage.pageId}"] [data-ppt-guard-root="1"]`
1188 const existingCount = editHistory.addElements.filter(
1189 (e) => e.pageId === selectedPage.pageId
1190 ).length
1191 const offset = existingCount * ADDED_TEXT_OFFSET_STEP
1192 const w = def.defaultWidth
1193 const h = def.defaultHeight
1194 const left = Math.min(
1195 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize!.width - w) / 2) + offset,
1196 slideSize!.width - w - ADDED_ELEMENT_EDGE_PADDING
1197 )
1198 const top = Math.min(
1199 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize!.height - h) / 2) + offset,
1200 slideSize!.height - h - ADDED_ELEMENT_EDGE_PADDING
1201 )
1202 const zIdx = 10 + existingCount
1203 const htmlFragment = buildShapeElementHtml({
1204 blockId,
1205 type,
1206 left,
1207 top,
1208 width: w,
1209 height: h,
1210 zIndex: zIdx
1211 })
1212
1213 useEditSessionStore.getState().commitCurrentDraft()
1214 editHistory.addElement({
1215 pageId: selectedPage.pageId,
1216 htmlPath: selectedPage.htmlPath,
1217 parentSelector,
1218 htmlFragment,
1219 assignedBlockId: blockId,
1220 insertIndex: -1
1221 })
1222 previewIframeRef.current?.injectElement(parentSelector, htmlFragment)
1223
1224 const selector = `body[data-page-id="${selectedPage.pageId}"] [data-block-id="${blockId}"]`
1225 if (useSessionDetailUiStore.getState().selectedPageId !== selectedPage.id) return
1226 const snapshot = await readElementSnapshotWithRetry(selector)
1227 if (!snapshot) return
1228 useEditSessionStore.getState().selectElement(
1229 buildSelectedElementFromSnapshot({
1230 selector,
1231 blockId,
1232 snapshot
1233 })
1234 )
1235 }
1236
1237 const handleAddIconElement = async (iconId: string): Promise<void> => {
1238 if (!id || !selectedPage?.pageId || !selectedPage.htmlPath) return
1239 const blockId = 'select-arcsin1-' + nanoid(8)
1240 const parentSelector = `body[data-page-id="${selectedPage.pageId}"] [data-ppt-guard-root="1"]`
1241 const existingCount = editHistory.addElements.filter(
1242 (e) => e.pageId === selectedPage.pageId
1243 ).length
1244 const offset = existingCount * ADDED_TEXT_OFFSET_STEP
1245 const w = ADDED_ICON_SIZE
1246 const h = ADDED_ICON_SIZE
1247 const left = Math.min(
1248 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize!.width - w) / 2) + offset,
1249 slideSize!.width - w - ADDED_ELEMENT_EDGE_PADDING
1250 )
1251 const top = Math.min(
1252 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize!.height - h) / 2) + offset,
1253 slideSize!.height - h - ADDED_ELEMENT_EDGE_PADDING
1254 )
1255 const zIdx = 10 + existingCount
1256 const htmlFragment = buildIconElementHtml({
1257 blockId,
1258 iconId,
1259 left,
1260 top,
1261 width: w,
1262 height: h,
1263 zIndex: zIdx
1264 })
1265
1266 useEditSessionStore.getState().commitCurrentDraft()
1267 editHistory.addElement({
1268 pageId: selectedPage.pageId,
1269 htmlPath: selectedPage.htmlPath,
1270 parentSelector,
1271 htmlFragment,
1272 assignedBlockId: blockId,
1273 insertIndex: -1
1274 })
1275 previewIframeRef.current?.injectElement(parentSelector, htmlFragment)
1276
1277 const selector = `body[data-page-id="${selectedPage.pageId}"] [data-block-id="${blockId}"]`
1278 if (useSessionDetailUiStore.getState().selectedPageId !== selectedPage.id) return
1279 const snapshot = await readElementSnapshotWithRetry(selector)
1280 if (!snapshot) return
1281 useEditSessionStore.getState().selectElement(
1282 buildSelectedElementFromSnapshot({
1283 selector,
1284 blockId,
1285 snapshot
1286 })
1287 )
1288 }
1289
1290 const handleAddChartElement = async (type: InsertChartType): Promise<void> => {
1291 if (!id || !selectedPage?.pageId || !selectedPage.htmlPath) return
1292 const blockId = 'select-arcsin1-' + nanoid(8)
1293 const parentSelector = `body[data-page-id="${selectedPage.pageId}"] [data-ppt-guard-root="1"]`
1294 const existingCount = editHistory.addElements.filter(
1295 (e) => e.pageId === selectedPage.pageId
1296 ).length
1297 const offset = existingCount * ADDED_TEXT_OFFSET_STEP
1298 const w = ADDED_CHART_WIDTH
1299 const h = ADDED_CHART_HEIGHT
1300 const left = Math.min(
1301 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize!.width - w) / 2) + offset,
1302 slideSize!.width - w - ADDED_ELEMENT_EDGE_PADDING
1303 )
1304 const top = Math.min(
1305 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize!.height - h) / 2) + offset,
1306 slideSize!.height - h - ADDED_ELEMENT_EDGE_PADDING
1307 )
1308 const zIdx = 10 + existingCount
1309 const htmlFragment = buildChartElementHtml(
1310 {
1311 blockId,
1312 left,
1313 top,
1314 width: w,
1315 height: h,
1316 zIndex: zIdx
1317 },
1318 DEFAULT_CHART_DATA[type] || DEFAULT_CHART_DATA.bar
1319 )
1320
1321 useEditSessionStore.getState().commitCurrentDraft()
1322 editHistory.addElement({
1323 pageId: selectedPage.pageId,
1324 htmlPath: selectedPage.htmlPath,
1325 parentSelector,
1326 htmlFragment,
1327 assignedBlockId: blockId,
1328 insertIndex: -1
1329 })
1330 previewIframeRef.current?.injectElement(parentSelector, htmlFragment)
1331
1332 const selector = `body[data-page-id="${selectedPage.pageId}"] [data-block-id="${blockId}"]`
1333 if (useSessionDetailUiStore.getState().selectedPageId !== selectedPage.id) return
1334 const snapshot = await readElementSnapshotWithRetry(selector)
1335 if (!snapshot) return
1336 useEditSessionStore.getState().selectElement(
1337 buildSelectedElementFromSnapshot({
1338 selector,
1339 blockId,
1340 snapshot
1341 })
1342 )
1343 }
1344
1345 const handleAddFormulaElement = async (): Promise<void> => {
1346 if (!id || !selectedPage?.pageId || !selectedPage.htmlPath) return
1347 const rendered = renderFormulaToHtml(DEFAULT_FORMULA_LATEX, true)
1348 if (!rendered.html) return
1349 const blockId = 'select-arcsin1-' + nanoid(8)
1350 const parentSelector = `body[data-page-id="${selectedPage.pageId}"] [data-ppt-guard-root="1"]`
1351 const existingCount = editHistory.addElements.filter(
1352 (e) => e.pageId === selectedPage.pageId
1353 ).length
1354 const offset = existingCount * ADDED_TEXT_OFFSET_STEP
1355 const w = ADDED_FORMULA_WIDTH
1356 const h = ADDED_FORMULA_HEIGHT
1357 const left = Math.min(
1358 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize!.width - w) / 2) + offset,
1359 slideSize!.width - w - ADDED_ELEMENT_EDGE_PADDING
1360 )
1361 const top = Math.min(
1362 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize!.height - h) / 2) + offset,
1363 slideSize!.height - h - ADDED_ELEMENT_EDGE_PADDING
1364 )
1365 const zIdx = 10 + existingCount
1366 const formulaStyle = [
1367 'position:absolute',
1368 `left:${left}px`,
1369 `top:${top}px`,
1370 `width:${w}px`,
1371 `height:${h}px`,
1372 `z-index:${zIdx}`,
1373 'display:flex',
1374 'align-items:center',
1375 'justify-content:center',
1376 'box-sizing:border-box',
1377 'padding:8px',
1378 'color:#111827',
1379 'font-size:30px',
1380 'line-height:1.2'
1381 ].join('; ')
1382 const htmlFragment = `<div data-block-id="${blockId}" data-ppt-edit-kind="formula" style="${formulaStyle};">${rendered.html}</div>`
1383
1384 useEditSessionStore.getState().commitCurrentDraft()
1385 editHistory.addElement({
1386 pageId: selectedPage.pageId,
1387 htmlPath: selectedPage.htmlPath,
1388 parentSelector,
1389 htmlFragment,
1390 assignedBlockId: blockId,
1391 insertIndex: -1
1392 })
1393 previewIframeRef.current?.injectElement(parentSelector, htmlFragment)
1394
1395 const selector = `body[data-page-id="${selectedPage.pageId}"] [data-block-id="${blockId}"]`
1396 if (useSessionDetailUiStore.getState().selectedPageId !== selectedPage.id) return
1397 const snapshot = await readElementSnapshotWithRetry(selector)
1398 if (!snapshot) return
1399 useEditSessionStore.getState().selectElement(
1400 buildSelectedElementFromSnapshot({
1401 selector,
1402 blockId,
1403 snapshot
1404 })
1405 )
1406 }
1407
1408 const handleAddElement = async (
1409 relativePath: string,
1410 _fileName: string,
1411 options: AddSessionElementOptions = {}
1412 ): Promise<boolean> => {
1413 if (!id || !selectedPage?.pageId || !selectedPage.htmlPath || !slideSize) return false
1414 const selectedHtmlPath = selectedPage.htmlPath
1415 const blockId = 'select-arcsin1-' + nanoid(8)
1416 const parentSelector = `body[data-page-id="${selectedPage.pageId}"] [data-ppt-guard-root="1"]`
1417 const isVideo = /^\.\/videos\//i.test(relativePath)
1418 const isBackground = Boolean(options.asBackground && !isVideo)
1419 if (isBackground) previewIframeRef.current?.clearEditModeSelection()
1420 const safeRelativePath = escapeHtmlText(relativePath)
1421 // Offset each added element so they don't overlap
1422 const existingCount = editHistory.addElements.filter(
1423 (e) => e.pageId === selectedPage.pageId
1424 ).length
1425 const offset = existingCount * ADDED_MEDIA_OFFSET_STEP
1426 const w = isBackground ? slideSize.width : isVideo ? 640 : 400
1427 const h = isBackground ? slideSize.height : isVideo ? 360 : 300
1428 const left = isBackground
1429 ? 0
1430 : Math.min(
1431 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize.width - w) / 2) + offset,
1432 slideSize.width - w - ADDED_ELEMENT_EDGE_PADDING
1433 )
1434 const top = isBackground
1435 ? 0
1436 : Math.min(
1437 Math.max(ADDED_ELEMENT_EDGE_PADDING, (slideSize.height - h) / 2) + offset,
1438 slideSize.height - h - ADDED_ELEMENT_EDGE_PADDING
1439 )
1440 const zIdx = isBackground ? 0 : 10 + existingCount
1441 const insertIndex = -1
1442 const objectFit = isBackground ? 'cover' : 'contain'
1443 const htmlFragment = isBackground
1444 ? `<style data-ppt-generated-background-style="1">body[data-page-id="${escapeCssString(selectedPage.pageId)}"] .ppt-page-root[data-ppt-guard-root="1"]{background:transparent !important;background-color:transparent !important;}</style><img src="${safeRelativePath}" alt="" data-block-id="${blockId}" data-ppt-generated-background="1" style="position:absolute; left:${left}px; top:${top}px; width:${w}px; height:${h}px; z-index:${zIdx}; object-fit:${objectFit}; opacity:0.5;" />`
1445 : isVideo
1446 ? `<video src="${safeRelativePath}" data-block-id="${blockId}" style="position:absolute; left:${left}px; top:${top}px; width:${w}px; height:${h}px; z-index:${zIdx}; object-fit:${objectFit};" controls playsinline preload="metadata"></video>`
1447 : `<img src="${safeRelativePath}" alt="" data-block-id="${blockId}" style="position:absolute; left:${left}px; top:${top}px; width:${w}px; height:${h}px; z-index:${zIdx}; object-fit:${objectFit};" />`
1448 useEditSessionStore.getState().commitCurrentDraft()
1449 const addElementItem = {
1450 pageId: selectedPage.pageId,
1451 htmlPath: selectedPage.htmlPath,
1452 parentSelector,
1453 htmlFragment,
1454 assignedBlockId: blockId,
1455 insertIndex
1456 }
1457 const backgroundSelectors: string[] = [
1458 '[data-ppt-generated-background="1"]',
1459 '[data-ppt-generated-background-style="1"]'
1460 ]
1461 if (options.persistImmediately) {
1462 const result = await ipc.saveEditBatch({
1463 sessionId: id,
1464 htmlPath: selectedPage.htmlPath,
1465 pageId: selectedPage.pageId,
1466 dragEdits: [],
1467 textEdits: [],
1468 propertyEdits: [],
1469 deletes: isBackground
1470 ? backgroundSelectors.map((selector) => ({
1471 pageId: selectedPage.pageId,
1472 htmlPath: selectedPage.htmlPath,
1473 selector
1474 }))
1475 : [],
1476 addElements: [addElementItem],
1477 prompt: options.prompt || (isVideo ? '添加视频元素' : '添加图片元素')
1478 })
1479 if (!result.success) throw new Error(t('sessionDetail.layoutSaveFailed'))
1480 useSessionDetailUiStore.getState().bumpThumbnailVersion(selectedPage.pageId)
1481 } else {
1482 if (isBackground) {
1483 const deletes = backgroundSelectors.map((selector) => ({
1484 pageId: selectedPage.pageId,
1485 htmlPath: selectedHtmlPath,
1486 selector
1487 }))
1488 editHistory.addElementWithDeletes(addElementItem, deletes)
1489 } else {
1490 editHistory.addElement(addElementItem)
1491 }
1492 }
1493 if (isBackground) {
1494 backgroundSelectors.forEach((selector) => previewIframeRef.current?.hideElement(selector))
1495 }
1496 previewIframeRef.current?.injectElement(parentSelector, htmlFragment, insertIndex, true)
1497 const selector = `body[data-page-id="${selectedPage.pageId}"] [data-block-id="${blockId}"]`
1498 if (useSessionDetailUiStore.getState().selectedPageId !== selectedPage.id) return true
1499 const snapshot = await readElementSnapshotWithRetry(selector)
1500 if (snapshot) {
1501 useEditSessionStore.getState().selectElement(
1502 buildSelectedElementFromSnapshot({
1503 selector,
1504 blockId,
1505 snapshot
1506 })
1507 )
1508 }
1509 return true
1510 }
1511
1512 useEffect(() => {
1513 addElementHandlerRef.current = handleAddElement
1514 }, [handleAddElement])
1515
1516 useEffect(() => {
1517 setAddElementHandler(invokeAddElement)
1518 return () => setAddElementHandler(null)
1519 }, [invokeAddElement, setAddElementHandler])
1520
1521 const handleBackToSessions = (): void => {
1522 useGenerateStore.getState().reset()
1523 useSessionDetailUiStore.getState().resetForSessionChange()
1524 resetRuntimeState()
1525 navigate('/sessions')
1526 }
1527
1528 const handleAddFromLibrary = (assetType: 'image' | 'video'): void => {
1529 setAssetPickerOpen(true, assetType)
1530 }
1531
1532 const handleAddFromLocal = async (assetType: 'image' | 'video'): Promise<void> => {
1533 if (!id) return
1534 const result = await ipc.chooseAndUploadAssets(id, assetType)
1535 if (result.cancelled || !result.assets?.length) return
1536 const asset = result.assets[0]
1537 await handleAddElement(asset.relativePath, asset.originalName || asset.fileName)
1538 }
1539
1540 useWorkspaceRibbonActionsRegistration({
1541 onUndo: () => useEditSessionStore.getState().undo(),
1542 onRedo: () => useEditSessionStore.getState().redo(),
1543 onSaveCurrentPage: () => void useEditSessionStore.getState().save(),
1544 onDiscardAllEdits: () => useEditSessionStore.getState().discardAll(),
1545 onApplySelectedToAllPages: () => void useEditSessionStore.getState().applySelectedToAllPages(),
1546 onCopySelectedElement: () => void handleCopyElement(),
1547 onDeleteSelectedElement: () => useEditSessionStore.getState().deleteSelected(),
1548 onBackToSessions: handleBackToSessions,
1549 onAddFromLibrary: handleAddFromLibrary,
1550 onAddFromLocal: (type) => void handleAddFromLocal(type),
1551 onAddText: () => void handleAddTextElement(),
1552 onAddArtText: (templateId) => void handleAddArtTextElement(templateId),
1553 onAddShape: (type) => void handleAddShapeElement(type),
1554 onAddIcon: (iconId) => void handleAddIconElement(iconId),
1555 onAddChart: (type) => void handleAddChartElement(type),
1556 onAddFormula: () => void handleAddFormulaElement()
1557 })
1558
1559 if (!id || !slideSize) {
1560 return <div className="h-full bg-[#f5f1e8]" />
1561 }
1562
1563 return (
1564 <TooltipProvider delayDuration={180}>
1565 <div className="flex h-full min-h-0 flex-col bg-[#f5f1e8] text-foreground outline-none">
1566 <header className="app-drag-region app-titlebar relative shrink-0 bg-[#f5f1e8]/95 shadow-[0_10px_26px_rgba(93,107,77,0.055)] backdrop-blur-xl">
1567 <div
1568 className={`relative flex h-full items-center ${
1569 isMac ? '' : 'pr-[calc(var(--app-titlebar-control-safe-area)+16px)]'
1570 }`}
1571 >
1572 <div className="flex-1">
1573 <SessionToolbar sessionId={id} isSavingEdits={isSavingEdits} />
1574 </div>
1575 </div>
1576 </header>
1577
1578 <div className="flex min-h-0 flex-1 flex-col bg-[#f5f1e8]">
1579 <WorkspaceRibbon isSavingEdits={isSavingEdits} />
1580 <StyleSwitchJobBar sessionId={id} />
1581
1582 {workspaceTab === 'browse' ? (
1583 <BrowseView sessionId={id} />
1584 ) : workspaceTab === 'style' ? (
1585 <StyleView sessionId={id} />
1586 ) : (
1587 <div className="flex min-h-0 flex-1">
1588 <PageSidebar sessionId={id} />
1589
1590 <div className="flex min-h-0 flex-1">
1591 <PreviewStage
1592 ref={handlePreviewIframe}
1593 selectedPage={selectedPage}
1594 sessionTitle={currentSession?.title}
1595 previewRefreshKey={previewRefreshKey}
1596 onElementMoved={(payload) => useEditSessionStore.getState().handleMoved(payload)}
1597 onElementSelected={(payload) =>
1598 useEditSessionStore.getState().selectElement(payload)
1599 }
1600 onCancelElementEdit={() => useEditSessionStore.getState().cancelEdit()}
1601 onDiscardAllEdits={() => useEditSessionStore.getState().discardAll()}
1602 onUndo={() => useEditSessionStore.getState().undo()}
1603 onRedo={() => useEditSessionStore.getState().redo()}
1604 onReplayPendingEdits={() => useEditSessionStore.getState().replayPending()}
1605 onDeleteRequest={(selector) => {
1606 setPendingDeleteSelector(selector)
1607 setDeleteConfirmOpen(true)
1608 }}
1609 />
1610 <SessionDetailRightPanel
1611 sessionId={id}
1612 elementInspector={
1613 elementSelection && !selectedPageStyleLocked ? (
1614 <ElementInspectorPanel
1615 selection={elementSelection}
1616 draft={elementDraft}
1617 onDraftChange={(draft, options) =>
1618 useEditSessionStore.getState().updateDraft(draft, options)
1619 }
1620 onClose={() => useEditSessionStore.getState().cancelEdit()}
1621 />
1622 ) : undefined
1623 }
1624 />
1625 </div>
1626 </div>
1627 )}
1628 </div>
1629
1630 <HistoryDialog sessionId={id} />
1631 <AddBlankPageDialog sessionId={id} />
1632 <AddPageDialog sessionId={id} />
1633 <MergeSessionPagesDialog sessionId={id} />
1634 <MergeTemplatePagesDialog sessionId={id} />
1635 <PageTitleEditDialog sessionId={id} />
1636 <DeletePageDialog sessionId={id} />
1637 <AssetPickerDialog
1638 sessionId={id}
1639 assetType={assetPickerType}
1640 open={assetPickerOpen}
1641 onClose={() => setAssetPickerOpen(false)}
1642 onConfirm={handleAddElement}
1643 />
1644 <DeleteElementDialog
1645 open={deleteConfirmOpen}
1646 onOpenChange={(open) => {
1647 setDeleteConfirmOpen(open)
1648 if (!open) setPendingDeleteSelector(null)
1649 }}
1650 onConfirm={() => {
1651 if (pendingDeleteSelector) {
1652 useEditSessionStore.getState().deleteBySelector(pendingDeleteSelector)
1653 } else {
1654 useEditSessionStore.getState().deleteSelected()
1655 }
1656 setPendingDeleteSelector(null)
1657 setDeleteConfirmOpen(false)
1658 }}
1659 />
1660 </div>
1661 </TooltipProvider>
1662 )
1663 }
1664
1664 lines Plain Text