返回 oh-my-ppt
PreviewStage.tsx
根目录 / src / renderer / src / components / session-detail / preview / PreviewStage.tsx
1 import { useCallback, useEffect, forwardRef, useRef, useState } from 'react'
2 import { Loader2, Sparkles } from 'lucide-react'
3 import {
4 isStyleSwitchPageLocked,
5 useGenerateStore,
6 useSessionDetailUiStore,
7 useSessionStore
8 } from '@renderer/store'
9 import { PreviewIframe, type PreviewIframeHandle } from '../../preview/PreviewIframe'
10 import type { EditModeMovePayload, EditSelectionPayload } from '@arcsin1/presentation-editor-runtime'
11 import type { SessionPreviewPage } from '../shared/types'
12 import { useT } from '@renderer/i18n'
13 import { EDITOR_INSET, EditorGuidesOverlay } from './EditorGuidesOverlay'
14 import { trySessionSlideSize } from '@shared/slide-size'
15
16 export const PreviewStage = forwardRef<
17 PreviewIframeHandle,
18 {
19 selectedPage: SessionPreviewPage | null
20 sessionTitle?: string | null
21 previewRefreshKey?: number
22 onElementMoved: (payload: EditModeMovePayload) => void
23 onElementSelected: (payload: EditSelectionPayload) => void
24 onCancelElementEdit: () => void
25 onDiscardAllEdits: () => void
26 onUndo: () => void
27 onRedo: () => void
28 onReplayPendingEdits: () => void
29 onDeleteRequest?: (selector: string) => void
30 }
31 >(function PreviewStage(
32 {
33 selectedPage,
34 previewRefreshKey = 0,
35 onElementMoved,
36 onElementSelected,
37 onCancelElementEdit,
38 onDiscardAllEdits,
39 onUndo,
40 onRedo,
41 onReplayPendingEdits,
42 onDeleteRequest
43 },
44 ref
45 ) {
46 const t = useT()
47 const previewIframeRef = useRef<PreviewIframeHandle>(null)
48 const frameRef = useRef<HTMLDivElement>(null)
49 const canvasHostRef = useRef<HTMLDivElement>(null)
50 const wasEditingRef = useRef(false)
51 const previewIdentityRef = useRef('')
52 const animationPreviewIdentityRef = useRef('')
53 const restoreTimerRef = useRef<number | null>(null)
54 const [previewReloadSignal, setPreviewReloadSignal] = useState(0)
55 const previewKey = useSessionDetailUiStore((state) => state.previewKey)
56 const currentSession = useSessionStore((state) => state.currentSession)
57 const slideSize = trySessionSlideSize(currentSession)
58 const interactionMode = useSessionDetailUiStore((state) => state.interactionMode)
59 const pageEditJob = useGenerateStore((state) =>
60 currentSession ? state.pageEditJobs[currentSession.id] || null : null
61 )
62 const pageBeautifyJob = useGenerateStore((state) =>
63 currentSession ? state.pageBeautifyJobs[currentSession.id] || null : null
64 )
65 const isDeckEditing = useGenerateStore((state) =>
66 currentSession ? Boolean(state.deckEditJobs[currentSession.id]) : false
67 )
68 const styleSwitchJob = useGenerateStore((state) =>
69 currentSession ? state.styleSwitchJobs[currentSession.id] || null : null
70 )
71 const setInteractionMode = useSessionDetailUiStore((state) => state.setInteractionMode)
72 const setWorkspaceTab = useSessionDetailUiStore((state) => state.setWorkspaceTab)
73 const editSelectedSelector = useSessionDetailUiStore((state) => state.editSelectedSelector)
74 const selectedSelector = useSessionDetailUiStore((state) => state.selectedSelector)
75 const setSelectedElement = useSessionDetailUiStore((state) => state.setSelectedElement)
76 const clearSelectedElement = useSessionDetailUiStore((state) => state.clearSelectedElement)
77 const isEditing = interactionMode === 'edit'
78 const isAnimationSelecting = interactionMode === 'animation-select'
79 const isInspecting = interactionMode === 'ai-inspect' || isAnimationSelecting
80 const isPageEditing = pageEditJob?.pageId === selectedPage?.pageId
81 const isPageBeautifying = pageBeautifyJob?.pageId === selectedPage?.pageId
82 const isGeneratingPlaceholder =
83 selectedPage?.status === 'generating' || selectedPage?.status === 'pending'
84 const isStyleSwitchLocked = isStyleSwitchPageLocked(styleSwitchJob, selectedPage?.pageId)
85 const isStyleSwitchPageRunning = styleSwitchJob?.pages.some(
86 (page) => page.pageId === selectedPage?.pageId && page.status === 'running'
87 )
88
89 const setPreviewIframeHandle = useCallback(
90 (handle: PreviewIframeHandle | null): void => {
91 previewIframeRef.current = handle
92 if (typeof ref === 'function') {
93 ref(handle)
94 return
95 }
96 if (ref) {
97 ref.current = handle
98 }
99 },
100 [ref]
101 )
102
103 const restoreEditSelection = useCallback((selector: string): void => {
104 if (restoreTimerRef.current !== null) {
105 window.clearTimeout(restoreTimerRef.current)
106 restoreTimerRef.current = null
107 }
108
109 let attempts = 0
110 const tryRestore = (): void => {
111 attempts += 1
112 window.requestAnimationFrame(() => {
113 const restorePromise = previewIframeRef.current?.restoreEditModeSelection(selector)
114 void restorePromise?.then((restored) => {
115 if (restored || attempts >= 3) return
116 restoreTimerRef.current = window.setTimeout(tryRestore, 50)
117 })
118 })
119 }
120
121 restoreTimerRef.current = window.setTimeout(tryRestore, 0)
122 }, [])
123
124 const restoreAnimationSelection = useCallback((selector: string): void => {
125 if (restoreTimerRef.current !== null) {
126 window.clearTimeout(restoreTimerRef.current)
127 restoreTimerRef.current = null
128 }
129
130 let attempts = 0
131 const tryRestore = (): void => {
132 attempts += 1
133 window.requestAnimationFrame(() => {
134 const restorePromise = previewIframeRef.current?.restoreInspectorSelection(selector)
135 void restorePromise?.then((restored) => {
136 if (restored || attempts >= 3) return
137 restoreTimerRef.current = window.setTimeout(tryRestore, 50)
138 })
139 })
140 }
141
142 restoreTimerRef.current = window.setTimeout(tryRestore, 0)
143 }, [])
144
145 useEffect(() => {
146 return () => {
147 if (restoreTimerRef.current !== null) {
148 window.clearTimeout(restoreTimerRef.current)
149 restoreTimerRef.current = null
150 }
151 }
152 }, [])
153
154 const handleDidReload = useCallback((): void => {
155 onReplayPendingEdits()
156 setPreviewReloadSignal((value) => value + 1)
157 if (isEditing && editSelectedSelector) {
158 restoreEditSelection(editSelectedSelector)
159 return
160 }
161 if (isAnimationSelecting && selectedSelector) {
162 restoreAnimationSelection(selectedSelector)
163 }
164 }, [
165 editSelectedSelector,
166 isAnimationSelecting,
167 isEditing,
168 onReplayPendingEdits,
169 restoreAnimationSelection,
170 restoreEditSelection,
171 selectedSelector
172 ])
173
174 useEffect(() => {
175 const previewIdentity = `${selectedPage?.pageId || ''}:${previewKey}:${previewRefreshKey}`
176 const enteredEditMode = isEditing && !wasEditingRef.current
177 const previewChangedWhileEditing = isEditing && previewIdentity !== previewIdentityRef.current
178
179 wasEditingRef.current = isEditing
180 previewIdentityRef.current = previewIdentity
181
182 if (!isEditing || !editSelectedSelector) return
183 if (!enteredEditMode && !previewChangedWhileEditing) return
184
185 restoreEditSelection(editSelectedSelector)
186 return () => {
187 if (restoreTimerRef.current !== null) {
188 window.clearTimeout(restoreTimerRef.current)
189 restoreTimerRef.current = null
190 }
191 }
192 }, [
193 editSelectedSelector,
194 isEditing,
195 restoreEditSelection,
196 selectedPage?.pageId,
197 previewKey,
198 previewRefreshKey
199 ])
200
201 useEffect(() => {
202 if (!isAnimationSelecting || !selectedSelector) return
203 const previewIdentity = `${selectedPage?.pageId || ''}:${previewKey}:${previewRefreshKey}`
204 if (previewIdentity === animationPreviewIdentityRef.current) return
205 animationPreviewIdentityRef.current = previewIdentity
206 restoreAnimationSelection(selectedSelector)
207 }, [
208 isAnimationSelecting,
209 previewKey,
210 previewRefreshKey,
211 restoreAnimationSelection,
212 selectedPage?.pageId,
213 selectedSelector
214 ])
215
216 useEffect(() => {
217 if (isPageEditing || isStyleSwitchLocked) return
218 if (interactionMode === 'preview') return
219 const onKeyDown = (event: KeyboardEvent): void => {
220 const target = event.target
221 const isEditableTarget =
222 target instanceof Element &&
223 Boolean(
224 target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
225 )
226 if (isEditing && !isEditableTarget && (event.metaKey || event.ctrlKey)) {
227 const key = event.key.toLowerCase()
228 if (key === 'z') {
229 event.preventDefault()
230 if (event.shiftKey) onRedo()
231 else onUndo()
232 return
233 }
234 if (key === 'y') {
235 event.preventDefault()
236 onRedo()
237 return
238 }
239 }
240 if (event.key === 'Escape') {
241 if (isEditing) {
242 onDiscardAllEdits()
243 } else if (isAnimationSelecting && selectedSelector) {
244 clearSelectedElement()
245 } else {
246 setInteractionMode('preview')
247 setWorkspaceTab('preview')
248 onCancelElementEdit()
249 }
250 }
251 }
252 window.addEventListener('keydown', onKeyDown)
253 return () => window.removeEventListener('keydown', onKeyDown)
254 }, [
255 interactionMode,
256 isPageEditing,
257 isStyleSwitchLocked,
258 clearSelectedElement,
259 isAnimationSelecting,
260 isEditing,
261 onDiscardAllEdits,
262 onCancelElementEdit,
263 onUndo,
264 onRedo,
265 selectedSelector,
266 setInteractionMode,
267 setWorkspaceTab
268 ])
269
270 if (!slideSize) {
271 return <div className="min-h-0 flex-1 bg-[#f5f1e8]" />
272 }
273
274 return (
275 <main className="flex min-h-0 flex-1 flex-col px-3 pb-3 pt-1">
276 <div className="relative min-h-0 flex-1 overflow-hidden rounded-[2rem] bg-[#e8e0d0]/54 p-3 shadow-[0_18px_38px_rgba(93,107,77,0.11)]">
277 <div className="pointer-events-none absolute -right-16 -top-20 h-56 w-56 rounded-[30%_70%_70%_30%/30%_30%_70%_70%] bg-[#d4e4c1]/42" />
278 <div className="pointer-events-none absolute -bottom-24 left-8 h-48 w-64 rounded-[5%_95%_10%_90%/85%_15%_85%_15%] bg-[#c8b89e]/20" />
279 {selectedPage ? (
280 <div
281 ref={frameRef}
282 className="relative h-full overflow-hidden rounded-[1.55rem] bg-[#f5f1e8] shadow-[0_10px_24px_rgba(93,107,77,0.11)]"
283 >
284 {isGeneratingPlaceholder ? (
285 <div
286 className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[#f8f4eb] text-center text-[#4f6340]"
287 aria-live="polite"
288 >
289 <Loader2 className="h-7 w-7 animate-spin" />
290 <div className="max-w-sm px-6 text-sm font-medium">
291 {t('sessionDetail.activityProcessing')}
292 </div>
293 </div>
294 ) : (
295 <div
296 ref={canvasHostRef}
297 className={
298 isEditing
299 ? 'absolute bottom-2 right-2 overflow-hidden rounded-[1rem]'
300 : 'absolute inset-0 overflow-hidden rounded-[inherit]'
301 }
302 style={isEditing ? { left: EDITOR_INSET, top: EDITOR_INSET } : undefined}
303 >
304 <PreviewIframe
305 ref={setPreviewIframeHandle}
306 key={`preview-${selectedPage.pageId}-${previewKey}-${previewRefreshKey}`}
307 src={selectedPage.sourceUrl}
308 htmlPath={selectedPage.htmlPath}
309 pageId={selectedPage.pageId}
310 title={`preview-page-${selectedPage.pageNumber}`}
311 slideSize={slideSize}
312 inspectable={!isPageEditing && !isDeckEditing && !isStyleSwitchLocked}
313 interactionMode={interactionMode}
314 inspecting={
315 isInspecting && !isPageEditing && !isDeckEditing && !isStyleSwitchLocked
316 }
317 editMode={
318 isEditing &&
319 !isPageEditing &&
320 !isPageBeautifying &&
321 !isDeckEditing &&
322 !isStyleSwitchLocked
323 }
324 onSelectorSelected={setSelectedElement}
325 onElementMoved={onElementMoved}
326 onElementSelected={onElementSelected}
327 onInspectExit={() => {
328 if (isAnimationSelecting && selectedSelector) {
329 clearSelectedElement()
330 return
331 }
332 setInteractionMode('preview')
333 setWorkspaceTab('preview')
334 onCancelElementEdit()
335 }}
336 onDidReload={handleDidReload}
337 onDeleteRequest={onDeleteRequest}
338 />
339 </div>
340 )}
341
342 {isEditing && !isPageEditing && !isPageBeautifying && (
343 <EditorGuidesOverlay
344 selectedPageId={selectedPage.pageId}
345 frameRef={frameRef}
346 canvasHostRef={canvasHostRef}
347 previewIframeRef={previewIframeRef}
348 reloadSignal={previewReloadSignal}
349 slideSize={slideSize}
350 />
351 )}
352 {isStyleSwitchPageRunning && (
353 <div
354 className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-[#f5f1e8]/72 text-center text-[#4f6340] backdrop-blur-[1px]"
355 aria-live="polite"
356 >
357 <Loader2 className="h-7 w-7 animate-spin" />
358 <div className="max-w-sm px-6 text-sm font-medium">
359 {t('sessionDetail.styleSwitching')}
360 </div>
361 </div>
362 )}
363 {pageEditJob && isPageEditing && (
364 <div
365 className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-[#f5f1e8]/72 text-center text-[#4f6340] backdrop-blur-[1px]"
366 aria-live="polite"
367 >
368 <Loader2 className="h-7 w-7 animate-spin" />
369 <div className="max-w-sm px-6 text-sm font-medium">
370 {pageEditJob.label || t('sessionDetail.activityProcessing')}
371 </div>
372 <div className="text-xs tabular-nums text-[#6d7b5d]">
373 {Math.round(pageEditJob.progress)}%
374 </div>
375 </div>
376 )}
377 {pageBeautifyJob && isPageBeautifying && (
378 <div
379 className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-[#f5f1e8]/72 text-center text-[#4f6340] backdrop-blur-[1px]"
380 aria-live="polite"
381 >
382 <Loader2 className="h-7 w-7 animate-spin" />
383 <div className="max-w-sm px-6 text-sm font-medium">
384 {pageBeautifyJob.label || t('sessionDetail.pageBeautifying')}
385 </div>
386 <div className="text-xs tabular-nums text-[#6d7b5d]">
387 {Math.round(pageBeautifyJob.progress)}%
388 </div>
389 </div>
390 )}
391 {selectedPage.status === 'failed' && (
392 <div className="absolute bottom-5 left-5 z-20 max-w-[520px] rounded-[1rem] bg-[#fff4ef]/92 px-3 py-2 text-xs text-[#8e5a53] shadow-[0_10px_24px_rgba(142,90,83,0.12)] backdrop-blur-sm">
393 {t('sessionDetail.failedPageHint')}
394 </div>
395 )}
396 </div>
397 ) : (
398 <div className="relative flex h-full min-h-[420px] flex-col items-center justify-center gap-4 rounded-[1.55rem] bg-[#f5f1e8]/84 text-center text-[#5d6b4d] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.32)]">
399 <Sparkles className="h-7 w-7 text-[#8fbc8f]" />
400 <div className="space-y-1">
401 <p className="text-base font-medium text-[#3e4a32]">
402 {t('sessionDetail.emptyPreviewTitle')}
403 </p>
404 <p className="text-sm">{t('sessionDetail.briefHint')}</p>
405 </div>
406 </div>
407 )}
408 </div>
409 </main>
410 )
411 })
412
412 lines Plain Text