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