返回 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 isDeckEditing = useGenerateStore((state) =>
63 currentSession ? Boolean(state.deckEditJobs[currentSession.id]) : false
64 )
65 const styleSwitchJob = useGenerateStore((state) =>
66 currentSession ? state.styleSwitchJobs[currentSession.id] || null : null
67 )
68 const setInteractionMode = useSessionDetailUiStore((state) => state.setInteractionMode)
69 const setWorkspaceTab = useSessionDetailUiStore((state) => state.setWorkspaceTab)
70 const editSelectedSelector = useSessionDetailUiStore((state) => state.editSelectedSelector)
71 const selectedSelector = useSessionDetailUiStore((state) => state.selectedSelector)
72 const setSelectedElement = useSessionDetailUiStore((state) => state.setSelectedElement)
73 const clearSelectedElement = useSessionDetailUiStore((state) => state.clearSelectedElement)
74 const isEditing = interactionMode === 'edit'
75 const isAnimationSelecting = interactionMode === 'animation-select'
76 const isInspecting = interactionMode === 'ai-inspect' || isAnimationSelecting
77 const isPageEditing = pageEditJob?.pageId === selectedPage?.pageId
78 const isGeneratingPlaceholder =
79 selectedPage?.status === 'generating' || selectedPage?.status === 'pending'
80 const isStyleSwitchLocked = isStyleSwitchPageLocked(styleSwitchJob, selectedPage?.pageId)
81 const isStyleSwitchPageRunning = styleSwitchJob?.pages.some(
82 (page) => page.pageId === selectedPage?.pageId && page.status === 'running'
83 )
84
85 const setPreviewIframeHandle = useCallback(
86 (handle: PreviewIframeHandle | null): void => {
87 previewIframeRef.current = handle
88 if (typeof ref === 'function') {
89 ref(handle)
90 return
91 }
92 if (ref) {
93 ref.current = handle
94 }
95 },
96 [ref]
97 )
98
99 const restoreEditSelection = useCallback((selector: string): void => {
100 if (restoreTimerRef.current !== null) {
101 window.clearTimeout(restoreTimerRef.current)
102 restoreTimerRef.current = null
103 }
104
105 let attempts = 0
106 const tryRestore = (): void => {
107 attempts += 1
108 window.requestAnimationFrame(() => {
109 const restorePromise = previewIframeRef.current?.restoreEditModeSelection(selector)
110 void restorePromise?.then((restored) => {
111 if (restored || attempts >= 3) return
112 restoreTimerRef.current = window.setTimeout(tryRestore, 50)
113 })
114 })
115 }
116
117 restoreTimerRef.current = window.setTimeout(tryRestore, 0)
118 }, [])
119
120 const restoreAnimationSelection = useCallback((selector: string): void => {
121 if (restoreTimerRef.current !== null) {
122 window.clearTimeout(restoreTimerRef.current)
123 restoreTimerRef.current = null
124 }
125
126 let attempts = 0
127 const tryRestore = (): void => {
128 attempts += 1
129 window.requestAnimationFrame(() => {
130 const restorePromise = previewIframeRef.current?.restoreInspectorSelection(selector)
131 void restorePromise?.then((restored) => {
132 if (restored || attempts >= 3) return
133 restoreTimerRef.current = window.setTimeout(tryRestore, 50)
134 })
135 })
136 }
137
138 restoreTimerRef.current = window.setTimeout(tryRestore, 0)
139 }, [])
140
141 useEffect(() => {
142 return () => {
143 if (restoreTimerRef.current !== null) {
144 window.clearTimeout(restoreTimerRef.current)
145 restoreTimerRef.current = null
146 }
147 }
148 }, [])
149
150 const handleDidReload = useCallback((): void => {
151 onReplayPendingEdits()
152 setPreviewReloadSignal((value) => value + 1)
153 if (isEditing && editSelectedSelector) {
154 restoreEditSelection(editSelectedSelector)
155 return
156 }
157 if (isAnimationSelecting && selectedSelector) {
158 restoreAnimationSelection(selectedSelector)
159 }
160 }, [
161 editSelectedSelector,
162 isAnimationSelecting,
163 isEditing,
164 onReplayPendingEdits,
165 restoreAnimationSelection,
166 restoreEditSelection,
167 selectedSelector
168 ])
169
170 useEffect(() => {
171 const previewIdentity = `${selectedPage?.pageId || ''}:${previewKey}:${previewRefreshKey}`
172 const enteredEditMode = isEditing && !wasEditingRef.current
173 const previewChangedWhileEditing = isEditing && previewIdentity !== previewIdentityRef.current
174
175 wasEditingRef.current = isEditing
176 previewIdentityRef.current = previewIdentity
177
178 if (!isEditing || !editSelectedSelector) return
179 if (!enteredEditMode && !previewChangedWhileEditing) return
180
181 restoreEditSelection(editSelectedSelector)
182 return () => {
183 if (restoreTimerRef.current !== null) {
184 window.clearTimeout(restoreTimerRef.current)
185 restoreTimerRef.current = null
186 }
187 }
188 }, [
189 editSelectedSelector,
190 isEditing,
191 restoreEditSelection,
192 selectedPage?.pageId,
193 previewKey,
194 previewRefreshKey
195 ])
196
197 useEffect(() => {
198 if (!isAnimationSelecting || !selectedSelector) return
199 const previewIdentity = `${selectedPage?.pageId || ''}:${previewKey}:${previewRefreshKey}`
200 if (previewIdentity === animationPreviewIdentityRef.current) return
201 animationPreviewIdentityRef.current = previewIdentity
202 restoreAnimationSelection(selectedSelector)
203 }, [
204 isAnimationSelecting,
205 previewKey,
206 previewRefreshKey,
207 restoreAnimationSelection,
208 selectedPage?.pageId,
209 selectedSelector
210 ])
211
212 useEffect(() => {
213 if (isPageEditing || isStyleSwitchLocked) return
214 if (interactionMode === 'preview') return
215 const onKeyDown = (event: KeyboardEvent): void => {
216 const target = event.target
217 const isEditableTarget =
218 target instanceof Element &&
219 Boolean(
220 target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
221 )
222 if (isEditing && !isEditableTarget && (event.metaKey || event.ctrlKey)) {
223 const key = event.key.toLowerCase()
224 if (key === 'z') {
225 event.preventDefault()
226 if (event.shiftKey) onRedo()
227 else onUndo()
228 return
229 }
230 if (key === 'y') {
231 event.preventDefault()
232 onRedo()
233 return
234 }
235 }
236 if (event.key === 'Escape') {
237 if (isEditing) {
238 onDiscardAllEdits()
239 } else if (isAnimationSelecting && selectedSelector) {
240 clearSelectedElement()
241 } else {
242 setInteractionMode('preview')
243 setWorkspaceTab('preview')
244 onCancelElementEdit()
245 }
246 }
247 }
248 window.addEventListener('keydown', onKeyDown)
249 return () => window.removeEventListener('keydown', onKeyDown)
250 }, [
251 interactionMode,
252 isPageEditing,
253 isStyleSwitchLocked,
254 clearSelectedElement,
255 isAnimationSelecting,
256 isEditing,
257 onDiscardAllEdits,
258 onCancelElementEdit,
259 onUndo,
260 onRedo,
261 selectedSelector,
262 setInteractionMode,
263 setWorkspaceTab
264 ])
265
266 if (!slideSize) {
267 return <div className="min-h-0 flex-1 bg-[#f5f1e8]" />
268 }
269
270 return (
271 <main className="flex min-h-0 flex-1 flex-col px-3 pb-3 pt-1">
272 <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)]">
273 <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" />
274 <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" />
275 {selectedPage ? (
276 <div
277 ref={frameRef}
278 className="relative h-full overflow-hidden rounded-[1.55rem] bg-[#f5f1e8] shadow-[0_10px_24px_rgba(93,107,77,0.11)]"
279 >
280 {isGeneratingPlaceholder ? (
281 <div
282 className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[#f8f4eb] text-center text-[#4f6340]"
283 aria-live="polite"
284 >
285 <Loader2 className="h-7 w-7 animate-spin" />
286 <div className="max-w-sm px-6 text-sm font-medium">
287 {t('sessionDetail.activityProcessing')}
288 </div>
289 </div>
290 ) : (
291 <div
292 ref={canvasHostRef}
293 className={
294 isEditing
295 ? 'absolute bottom-2 right-2 overflow-hidden rounded-[1rem]'
296 : 'absolute inset-0 overflow-hidden rounded-[inherit]'
297 }
298 style={isEditing ? { left: EDITOR_INSET, top: EDITOR_INSET } : undefined}
299 >
300 <PreviewIframe
301 ref={setPreviewIframeHandle}
302 key={`preview-${selectedPage.pageId}-${previewKey}-${previewRefreshKey}`}
303 src={selectedPage.sourceUrl}
304 htmlPath={selectedPage.htmlPath}
305 pageId={selectedPage.pageId}
306 title={`preview-page-${selectedPage.pageNumber}`}
307 slideSize={slideSize}
308 inspectable={!isPageEditing && !isDeckEditing && !isStyleSwitchLocked}
309 interactionMode={interactionMode}
310 inspecting={
311 isInspecting && !isPageEditing && !isDeckEditing && !isStyleSwitchLocked
312 }
313 editMode={
314 isEditing &&
315 !isPageEditing &&
316 !isDeckEditing &&
317 !isStyleSwitchLocked
318 }
319 onSelectorSelected={setSelectedElement}
320 onElementMoved={onElementMoved}
321 onElementSelected={onElementSelected}
322 onInspectExit={() => {
323 if (isAnimationSelecting && selectedSelector) {
324 clearSelectedElement()
325 return
326 }
327 setInteractionMode('preview')
328 setWorkspaceTab('preview')
329 onCancelElementEdit()
330 }}
331 onDidReload={handleDidReload}
332 onDeleteRequest={onDeleteRequest}
333 />
334 </div>
335 )}
336
337 {isEditing && !isPageEditing && (
338 <EditorGuidesOverlay
339 selectedPageId={selectedPage.pageId}
340 frameRef={frameRef}
341 canvasHostRef={canvasHostRef}
342 previewIframeRef={previewIframeRef}
343 reloadSignal={previewReloadSignal}
344 slideSize={slideSize}
345 />
346 )}
347 {isStyleSwitchPageRunning && (
348 <div
349 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]"
350 aria-live="polite"
351 >
352 <Loader2 className="h-7 w-7 animate-spin" />
353 <div className="max-w-sm px-6 text-sm font-medium">
354 {t('sessionDetail.styleSwitching')}
355 </div>
356 </div>
357 )}
358 {pageEditJob && isPageEditing && (
359 <div
360 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]"
361 aria-live="polite"
362 >
363 <Loader2 className="h-7 w-7 animate-spin" />
364 <div className="max-w-sm px-6 text-sm font-medium">
365 {pageEditJob.label || t('sessionDetail.activityProcessing')}
366 </div>
367 <div className="text-xs tabular-nums text-[#6d7b5d]">
368 {Math.round(pageEditJob.progress)}%
369 </div>
370 </div>
371 )}
372 {selectedPage.status === 'failed' && (
373 <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">
374 {t('sessionDetail.failedPageHint')}
375 </div>
376 )}
377 </div>
378 ) : (
379 <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)]">
380 <Sparkles className="h-7 w-7 text-[#8fbc8f]" />
381 <div className="space-y-1">
382 <p className="text-base font-medium text-[#3e4a32]">
383 {t('sessionDetail.emptyPreviewTitle')}
384 </p>
385 <p className="text-sm">{t('sessionDetail.briefHint')}</p>
386 </div>
387 </div>
388 )}
389 </div>
390 </main>
391 )
392 })
393
393 lines Plain Text