| 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 |