import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js'; import Head from 'next/head'; import { useRouter } from 'next/router'; import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { flushSync } from 'react-dom'; import BaseButton from '../components/BaseButton'; import CanvasBackground from '../components/Constructor/CanvasBackground'; import CanvasLoadingSpinner from '../components/CanvasLoadingSpinner'; import TransitionBlackOverlay from '../components/TransitionBlackOverlay'; import ConstructorToolbar from '../components/Constructor/ConstructorToolbar'; import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay'; import CanvasElementComponent from '../components/Constructor/CanvasElement'; import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay'; import ElementEditorPanel from '../components/Constructor/ElementEditorPanel'; import { BackdropPortalProvider } from '../components/BackdropPortal'; import { getPageTitle } from '../config'; import LayoutAuthenticated from '../layouts/Authenticated'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { usePageNavigationState } from '../hooks/usePageNavigationState'; import { usePageNavigation } from '../hooks/usePageNavigation'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useTransitionSettings } from '../hooks/useTransitionSettings'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice'; import { fetchByProjectAndEnv as fetchProjectTransitionSettings, selectByProjectAndEnv as selectProjectTransitionSettings, } from '../stores/project_transition_settings/projectTransitionSettingsSlice'; import type { ElementTransitionSettings } from '../types/transition'; import { entityToProjectSettings, extractElementTransitionSettings, } from '../types/transition'; import { logger } from '../lib/logger'; import { isSafari } from '../lib/browserUtils'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { downloadManager } from '../lib/offline/DownloadManager'; import { parseJsonObject } from '../lib/parseJson'; import { resolveNavigationTarget, hasPlayableTransition, getNavigationDirection, isBackNavigation, } from '../lib/navigationHelpers'; import { mergeElementWithDefaults, createLocalId, normalizeAppearDelaySec, normalizeAppearDurationSec, ELEMENT_TYPE_LABELS, getNavigationButtonKind, isNavigationElementType, isTooltipElementType, isDescriptionElementType, isMediaElementType, isVideoPlayerElementType, clamp, } from '../lib/elementDefaults'; import type { CanvasElementType, CanvasElement, ConstructorSchema, EditorMenuItem, GalleryCard, GalleryInfoSpan, CarouselSlide, } from '../types/constructor'; import type { TourPage } from '../types/entities'; // Constructor-specific hooks import { useCanvasElapsedTime, isElementVisibleAtTime, } from '../hooks/useCanvasElapsedTime'; import { useMediaDurationProbe, buildDurationProbeTargets, } from '../hooks/useMediaDurationProbe'; import { useIconPreload } from '../hooks/useIconPreload'; import { useOutsideClick } from '../hooks/useOutsideClick'; import { useDraggable } from '../hooks/useDraggable'; import { useCanvasElementDrag } from '../hooks/useCanvasElementDrag'; import { useTransitionPreview } from '../hooks/useTransitionPreview'; import { useConstructorPageActions } from '../hooks/useConstructorPageActions'; import { useConstructorElements } from '../hooks/useConstructorElements'; import { usePageBackground } from '../hooks/usePageBackground'; import { useConstructorData } from '../hooks/useConstructorData'; import { useAssetOptions } from '../hooks/useAssetOptions'; import { usePublishStatus } from '../hooks/usePublishStatus'; import { ConstructorProvider, type ConstructorContextValue, type NavigationElementType, } from '../context/ConstructorContext'; import { useCanvasScale } from '../hooks/useCanvasScale'; import { useVideoSoundControl } from '../hooks/useVideoSoundControl'; import { useNetworkAware } from '../hooks/useNetworkAware'; // TourPage type is imported from '../types/entities' // NavigationElementType is imported from '../context/ConstructorContext' type ConstructorPageProps = { mode?: 'constructor' | 'element_edit'; }; type ConstructorInteractionMode = 'edit' | 'interact'; // Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup const labelByType = ELEMENT_TYPE_LABELS; const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { const router = useRouter(); const dispatch = useAppDispatch(); const globalTransitionDefaults = useAppSelector( (state) => state.global_transition_defaults.data, ); const canvasRef = useRef(null); const elementEditorRef = useRef(null); const toolbarRef = useRef(null); const [isAuthReady, setIsAuthReady] = useState(false); const isElementEditMode = mode === 'element_edit'; const projectId = useMemo(() => { const value = router.query.projectId; if (Array.isArray(value)) return value[0] || ''; return String(value || ''); }, [router.query.projectId]); const pageElementsListHref = useMemo(() => { if (!projectId) return '/project-element-defaults/project-element-defaults-list'; return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`; }, [projectId]); // Project transition settings from environment-aware store (constructor always uses 'dev') const projectTransitionSettingsEntity = useAppSelector((state) => projectId ? selectProjectTransitionSettings(state, projectId, 'dev') : undefined, ); const projectTransitionSettings = useMemo( () => entityToProjectSettings(projectTransitionSettingsEntity), [projectTransitionSettingsEntity], ); const pageIdFromRoute = useMemo(() => { const value = router.query.pageId; if (Array.isArray(value)) return value[0] || ''; return String(value || ''); }, [router.query.pageId]); const elementIdFromRoute = useMemo(() => { const value = router.query.elementId; if (Array.isArray(value)) return value[0] || ''; return String(value || ''); }, [router.query.elementId]); // Use React Query for data fetching (replaces manual loadData) const { project, pages, pageLinks, allPagesPreloadElements, assets, uiElementDefaultsByType, projectName, isLoading: isDataLoading, isError: isDataError, error: dataError, refetch: refetchData, } = useConstructorData({ projectId, isAuthReady, }); // Canvas scale for responsive UI elements and letterbox mode const { cssVars: canvasCssVars, letterboxStyles } = useCanvasScale({ designWidth: project?.design_width, designHeight: project?.design_height, }); // Page navigation with history tracking via shared hook const { currentPageId: activePageId, pageHistory, applyPageSelection, getNavigationContext, setCurrentPageId: setActivePageId, } = usePageNavigation({ pages, trackHistory: true, }); // Consolidated page background state (replaces 8 separate useState hooks) const { background: pageBackground, setBackground: setPageBackground, updateFromPage: updateBackgroundFromPage, setImageUrl: setBackgroundImageUrl, setVideoUrl: setBackgroundVideoUrl, setAudioUrl: setBackgroundAudioUrl, setVideoSettings: setBackgroundVideoSettings, // Legacy compatibility values for components that expect flat props backgroundImageUrl, backgroundVideoUrl, backgroundAudioUrl, backgroundVideoAutoplay, backgroundVideoLoop, backgroundVideoMuted, backgroundVideoStartTime, backgroundVideoEndTime, } = usePageBackground(); // Sound control hook for iOS autoplay compatibility // Videos start muted (for iOS autoplay), can be controlled via page settings const soundControl = useVideoSoundControl({ pageHasSound: backgroundVideoMuted === false, // Show button when page allows sound hasBackgroundVideo: Boolean(backgroundVideoUrl), videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset }); // Network-aware transitions: skip video on slow networks, use CSS fade instead const { shouldUseVideoTransitions, networkInfo } = useNetworkAware(); // Fetch global transition defaults on mount useEffect(() => { dispatch(fetchGlobalTransitionDefaults()); }, [dispatch]); // Fetch project transition settings for dev environment useEffect(() => { if (projectId) { dispatch( fetchProjectTransitionSettings({ projectId, environment: 'dev' }), ); } }, [dispatch, projectId]); const [selectedMenuItem, setSelectedMenuItem] = useState('none'); // Transition preview state managed by useTransitionPreview hook (below) // Combined loading state: initial auth check + data loading const [isInitializing, setIsInitializing] = useState(true); const isLoading = isInitializing || isDataLoading; // isSaving, isSavingToStage, isCreatingPage are managed by useConstructorPageActions hook const [newTransitionName, setNewTransitionName] = useState(''); const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState(''); const [newTransitionSupportsReverse, setNewTransitionSupportsReverse] = useState(true); const [errorMessage, setErrorMessage] = useState(''); const [successMessage, setSuccessMessage] = useState(''); // Auto-dismiss toast messages after 5 seconds useEffect(() => { if (!errorMessage) return; const timer = setTimeout(() => setErrorMessage(''), 5000); return () => clearTimeout(timer); }, [errorMessage]); useEffect(() => { if (!successMessage) return; const timer = setTimeout(() => setSuccessMessage(''), 5000); return () => clearTimeout(timer); }, [successMessage]); const [constructorInteractionMode, setConstructorInteractionMode] = useState('edit'); // Note: isMenuOpen kept for context backward compatibility, not used by ConstructorToolbar const [isMenuOpen, setIsMenuOpen] = useState(true); const [isEditorCollapsed, setIsEditorCollapsed] = useState(false); const [elementEditorTab, setElementEditorTab] = useState< 'general' | 'css' | 'effects' >('general'); const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{ elementId: string; initialIndex: number; } | null>(null); // Current element transition settings (for CSS transitions when no video) const [ currentElementTransitionSettings, setCurrentElementTransitionSettings, ] = useState(null); const isConstructorEditMode = constructorInteractionMode === 'edit'; const allowedNavigationTypes = useMemo(() => { return ['navigation_next', 'navigation_prev']; }, []); // Element CRUD operations via useConstructorElements hook const { elements, setElements, selectedElementId, selectedElement, selectElement, clearSelection, addElement, updateSelectedElement, removeSelectedElement, galleryCards, galleryInfoSpans, carouselSlides, updateElementPosition, normalizeNavigationType, } = useConstructorElements({ initialElements: [], elementDefaultsByType: uiElementDefaultsByType, allowedNavigationTypes, initialSelectedElementId: elementIdFromRoute, onElementSelected: useCallback(() => { setSelectedMenuItem('none'); }, []), onSelectionCleared: useCallback(() => { setSelectedMenuItem('none'); }, []), onElementAdded: useCallback(() => { setSuccessMessage('Element added. Drag it to set position.'); setErrorMessage(''); }, []), onElementRemoved: useCallback(() => { setSuccessMessage('Element removed.'); }, []), }); // Check if any element has full-width carousel mode enabled const hasFullWidthCarousel = useMemo( () => elements.some((el) => el.carouselFullWidth === true), [elements], ); // Look up current element for gallery carousel (so it receives updates from element editor) const activeGalleryCarouselElement = useMemo(() => { if (!activeGalleryCarousel) return null; return ( elements.find((el) => el.id === activeGalleryCarousel.elementId) || null ); }, [activeGalleryCarousel, elements]); // Draggable panels using useDraggable hook const { position: toolbarPosition, onDragStart: onToolbarDragStart } = useDraggable({ initialPosition: { x: 20, y: 20 }, elementWidth: 200, // Use collapsed width for bounds - expanded can go off-screen elementHeight: 56, }); const { position: editorPosition, onDragStart: onElementEditorDragStart } = useDraggable({ initialPosition: { x: 9999, y: 0 }, // Top-right corner (x will be clamped) elementWidth: isEditorCollapsed ? 220 : 300, elementHeight: 60, }); const transitionVideoRef = useRef(null); const lastInitializedPageIdRef = useRef(null); const didSetInitialCanvasFocus = useRef(false); const selectedElementIdRef = useRef(''); selectedElementIdRef.current = selectedElementId; const activePage = useMemo( () => pages.find((item) => item.id === activePageId) || null, [activePageId, pages], ); // Last project-level save: most recent updatedAt across all pages const lastProjectSaveAt = useMemo(() => { if (!pages.length) return null; return pages.reduce( (latest, page) => { if (!page.updatedAt) return latest; if (!latest) return page.updatedAt; return new Date(page.updatedAt) > new Date(latest) ? page.updatedAt : latest; }, null as string | null, ); }, [pages]); // Transition preview state management const { preview: transitionPreview, pendingPageId: pendingNavigationPageId, openPreview: openTransitionPreviewForElement, openPreviewWithTarget, closePreview: closeTransitionPreview, } = useTransitionPreview({ isNavigationElementType, onError: setErrorMessage, }); // Canvas elapsed time for element visibility timing const { elapsedSec: canvasElapsedSec } = useCanvasElapsedTime({ pageId: activePageId, enabled: !isLoading, }); // Element dragging with percentage positioning const { onElementDragStart, cancelDrag: cancelElementDrag } = useCanvasElementDrag({ canvasRef, onPositionChange: updateElementPosition, enabled: constructorInteractionMode === 'edit', }); // Preload orchestrator for better DX when previewing pages // Preloads current page + transition videos only // STREAM-FIRST: Constructor always uses online mode // Transition videos stream on-demand, then cache for replay const preloadOrchestrator = usePreloadOrchestrator({ pages: pages.map((p) => ({ id: p.id, background_image_url: p.background_image_url, background_video_url: p.background_video_url, background_audio_url: p.background_audio_url, })), pageLinks, elements: allPagesPreloadElements, currentPageId: activePageId, pageHistory, enabled: !isLoading && !!activePageId, }); // Resolve transition settings using cascade: element → project → global const transitionSettings = useTransitionSettings({ globalDefaults: globalTransitionDefaults, projectSettings: projectTransitionSettings ?? null, elementSettings: currentElementTransitionSettings, }); // Unified page navigation state machine (replaces 6+ separate hooks) // Uses useReducer for atomic state transitions, preventing race conditions const navState = usePageNavigationState({ preloadCache: preloadOrchestrator ? { getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, preloadedUrls: preloadOrchestrator.preloadedUrls, } : undefined, transitionSettings, }); // Destructure for convenience (matches previous hook interfaces) // showElements/showSpinner are derived from the unified state machine phase: // - showElements: true when phase is 'idle' or 'fading_in' // - showSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done' const { currentImageUrl: navCurrentBgImageUrl, currentVideoUrl: navCurrentBgVideoUrl, currentAudioUrl: navCurrentBgAudioUrl, previousImageUrl: navPreviousBgImageUrl, previousVideoUrl: navPreviousBgVideoUrl, isSwitching: navIsSwitching, isNewBgReady: navIsNewBgReady, pendingTransitionComplete, isFadingIn, showElements: navShowElements, showSpinner: navShowSpinner, showTransitionVideo, transitionStyle, lastKnownBgUrl, onBackgroundReady: navOnBackgroundReady, onVideoBufferStateChange, onTransitionEnded, navigateToPage: navNavigateToPage, resetToIdle: navResetToIdle, startTransition, } = navState; // Reset navigation state when starting a new transition const resetFadeIn = useCallback(() => { navResetToIdle(); }, [navResetToIdle]); // Helper to switch pages without flash // Uses unified navigation state machine for blob URL resolution // isBack parameter indicates this is a back navigation (pops history instead of pushing) const switchToPage = useCallback( async (page: TourPage | null, isBack = false) => { // Mark this page as initialized to prevent redundant effect calls if (page) { lastInitializedPageIdRef.current = page.id; } // Use unified navigation state machine for atomic state transitions await navNavigateToPage( page ? { id: page.id, background_image_url: page.background_image_url, background_video_url: page.background_video_url, background_audio_url: page.background_audio_url, } : null, { hasTransition: false, // No video transition for direct navigation isBack, onSwitched: () => { if (page) { // Use applyPageSelection for proper history management (pops on back) applyPageSelection(page.id, isBack); } }, }, ); }, [navNavigateToPage, applyPageSelection], ); const { isBuffering: isTransitionBuffering, isVideoReady: isTransitionVideoReady } = useTransitionPlayback({ videoRef: transitionVideoRef, transition: transitionPreview ? { videoUrl: resolveAssetPlaybackUrl(transitionPreview.videoUrl), storageKey: transitionPreview.storageKey, reverseMode: transitionPreview.reverseMode, reverseVideoUrl: transitionPreview.reverseVideoUrl ? resolveAssetPlaybackUrl(transitionPreview.reverseVideoUrl) : undefined, reverseStorageKey: transitionPreview.reverseStorageKey, // Raw path for cache lookup durationSec: transitionPreview.durationSec, targetPageId: pendingNavigationPageId || undefined, displayName: transitionPreview.title, isBack: transitionPreview.isBack, // Pass through for history management } : null, onComplete: async (targetPageId, isBack) => { // Resume background downloads now that transition is complete downloadManager.resumeAll(); const video = transitionVideoRef.current; if (targetPageId) { const targetPage = pages.find((p) => p.id === targetPageId) || null; // Signal that transition video has ended // State machine transitions to 'transition_done', waiting for background onTransitionEnded(); // DON'T close preview here - it stays visible until background is ready // The useEffect watching showTransitionVideo will close it // Navigate to target page - state machine handles ready state await switchToPage(targetPage, isBack ?? false); clearSelection(); setSelectedMenuItem('none'); setErrorMessage(''); } else { video?.removeAttribute('src'); video?.load(); closeTransitionPreview(); navResetToIdle(); } }, timeouts: { playbackStartMs: 3000, hardTimeoutMs: 45000, }, features: { useBlobUrl: true, preDecodeImages: false, // We handle image loading via navigation state machine }, preload: { preloadedUrls: preloadOrchestrator.preloadedUrls, getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, getReadyBlob: preloadOrchestrator.getReadyBlob, }, }); // Sync transition video buffering state with navigation state machine // This enables unified showSpinner logic in the state machine useEffect(() => { const isBuffering = Boolean(transitionPreview) && isTransitionBuffering; onVideoBufferStateChange(isBuffering); }, [transitionPreview, isTransitionBuffering, onVideoBufferStateChange]); // Clean up transition preview when state machine says video overlay should be hidden // showTransitionVideo is true during 'transitioning', 'transition_done', and 'fading_in' phases // During 'fading_in', the overlay fades out (isFadingOut=true), then removed when phase goes to 'idle' useEffect(() => { if (transitionPreview && !showTransitionVideo) { closeTransitionPreview(); } }, [transitionPreview, showTransitionVideo, closeTransitionPreview]); const iconPreloadTargets = useMemo(() => { const preloadableTypes: CanvasElementType[] = [ 'navigation_next', 'navigation_prev', 'tooltip', 'description', ]; const urls = elements .filter( (element) => preloadableTypes.includes(element.type) && Boolean(element.iconUrl), ) .map((element) => resolveAssetPlaybackUrl(element.iconUrl)) .filter(Boolean); return Array.from(new Set(urls)); }, [elements]); // Icon preloading for smooth rendering const { preloadedUrlMap: preloadedIconUrlMap } = useIconPreload({ iconUrls: iconPreloadTargets, enabled: !isLoading, }); // Use the useAssetOptions hook to derive memoized asset options const assetOptions = useAssetOptions({ assets }); // Media duration probing with caching const durationProbeTargets = useMemo( () => buildDurationProbeTargets({ backgroundVideoUrl, backgroundAudioUrl, selectedElement, newTransitionVideoUrl, elements, isMediaElementType, isVideoPlayerElementType, isNavigationElementType, }), [ backgroundAudioUrl, backgroundVideoUrl, elements, newTransitionVideoUrl, selectedElement, ], ); const { getDuration, getDurationNote, durationBySource } = useMediaDurationProbe({ targets: durationProbeTargets, }); const backgroundVideoDurationNote = getDurationNote(backgroundVideoUrl); const backgroundAudioDurationNote = getDurationNote(backgroundAudioUrl); const selectedMediaDurationNote = useMemo(() => { if (!selectedElement || !isMediaElementType(selectedElement.type)) { return 'Duration: unknown'; } return getDurationNote(selectedElement.mediaUrl || ''); }, [getDurationNote, selectedElement]); const newTransitionDurationNote = getDurationNote(newTransitionVideoUrl); const selectedTransitionDurationNote = useMemo(() => { if (!selectedElement || !isNavigationElementType(selectedElement.type)) { return 'Duration: unknown'; } return getDurationNote(selectedElement.transitionVideoUrl || ''); }, [getDurationNote, selectedElement]); useEffect(() => { if (newTransitionVideoUrl) return; if (!assetOptions.transitionVideo.length) return; setNewTransitionVideoUrl(assetOptions.transitionVideo[0].value); }, [newTransitionVideoUrl, assetOptions.transitionVideo]); useEffect(() => { setElements((prev) => { let hasChanges = false; const next = prev.map((element) => { if (!isNavigationElementType(element.type)) return element; const resolvedDuration = getDuration(element.transitionVideoUrl || ''); const nextDuration = Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0 ? Number(resolvedDuration) : undefined; if (element.transitionDurationSec === nextDuration) return element; hasChanges = true; return { ...element, transitionDurationSec: nextDuration, }; }); return hasChanges ? next : prev; }); }, [getDuration]); // Handle initial page selection when pages are loaded const prevPagesLengthRef = useRef(0); useEffect(() => { // Only set initial page when pages first load (not on every change) if (pages.length > 0 && prevPagesLengthRef.current === 0) { const defaultPageId = pageIdFromRoute || pages[0]?.id || ''; // Use applyPageSelection to set initial page and history applyPageSelection(defaultPageId, false); setIsMenuOpen(false); setIsInitializing(false); } prevPagesLengthRef.current = pages.length; }, [pages, pageIdFromRoute, applyPageSelection]); // Handle empty project case - mark initialized when data loads with no pages // This allows the "Create First Page" button to show useEffect(() => { if (!isDataLoading && pages.length === 0) { setIsInitializing(false); } }, [isDataLoading, pages.length]); // Handle query errors useEffect(() => { if (isDataError && dataError) { const message = dataError.message || 'Failed to load constructor data.'; logger.error('Failed to load constructor data:', dataError); setErrorMessage(message); } }, [isDataError, dataError]); // Refetch wrapper that preserves current page const handleReload = useCallback(async () => { const currentPageId = activePageId; await refetchData(); // After refetch, restore the active page if it still exists // This is handled by the pages effect above if (currentPageId && pages.some((p) => p.id === currentPageId)) { setActivePageId(currentPageId); } }, [activePageId, pages, refetchData]); // Page actions (save, create page, save to stage) const { isSaving, isSavingToStage, isCreatingPage, isCreatingTransition, saveConstructor, saveToStage, createPage, createTransition, } = useConstructorPageActions({ projectId, project, pages, activePage, activePageId, elements, pageBackground, onReload: handleReload, onSetActivePageId: setActivePageId, onSetMenuOpen: setIsMenuOpen, onError: setErrorMessage, onSuccess: setSuccessMessage, }); // Publish status for timestamp display const { lastSavedToStage, refresh: refreshPublishStatus } = usePublishStatus({ projectId, }); // Wrap saveToStage to refresh publish status after save const handleSaveToStage = useCallback(async () => { await saveToStage(); await refreshPublishStatus(); }, [saveToStage, refreshPublishStatus]); useEffect(() => { if (!router.isReady || typeof window === 'undefined') return; const token = sessionStorage.getItem('token') || localStorage.getItem('token'); if (!token) { setIsAuthReady(false); setErrorMessage('Please sign in to continue.'); router.replace('/login'); return; } setIsAuthReady(true); }, [router]); useEffect(() => { if (!router.isReady) return; if (projectId) return; router.replace( isElementEditMode ? '/project-element-defaults/project-element-defaults-list' : '/projects/projects-list', ); }, [isElementEditMode, projectId, router]); // React Query handles data fetching automatically based on projectId and isAuthReady // Panel initial positions are handled by useDraggable hooks useEffect(() => { if (!router.isReady || !isAuthReady || isLoading) return; if (didSetInitialCanvasFocus.current) return; if (!canvasRef.current) return; didSetInitialCanvasFocus.current = true; requestAnimationFrame(() => { canvasRef.current?.focus({ preventScroll: true }); }); }, [isAuthReady, isLoading, router.isReady]); useEffect(() => { if (!activePage) { setElements([]); clearSelection(); updateBackgroundFromPage(null); return; } const schema = parseJsonObject( activePage.ui_schema_json, {}, ); const normalizedElements = Array.isArray(schema.elements) ? schema.elements .filter( (item) => item && item.type && labelByType[item.type as CanvasElementType], ) .map((item) => { const elementType = item.type as CanvasElementType; const normalizedElement: CanvasElement = { ...item, id: String(item.id || createLocalId()), label: typeof item.label === 'string' && item.label.trim() ? item.label : labelByType[elementType], xPercent: clamp(Number(item.xPercent || 0), 0, 100), yPercent: clamp(Number(item.yPercent || 0), 0, 100), appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec), appearDurationSec: normalizeAppearDurationSec( item.appearDurationSec, ), galleryCards: Array.isArray(item.galleryCards) ? item.galleryCards.map((card: Partial) => ({ id: String(card?.id || createLocalId()), imageUrl: String(card?.imageUrl ?? ''), title: String(card?.title ?? ''), description: String(card?.description ?? ''), })) : undefined, galleryHeaderImageUrl: typeof item.galleryHeaderImageUrl === 'string' ? item.galleryHeaderImageUrl : undefined, galleryTitle: typeof item.galleryTitle === 'string' ? item.galleryTitle : undefined, galleryInfoSpans: Array.isArray(item.galleryInfoSpans) ? item.galleryInfoSpans.map( (span: Partial) => ({ id: String(span?.id || createLocalId()), text: String(span?.text ?? ''), iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined, }), ) : undefined, galleryColumns: typeof item.galleryColumns === 'number' ? item.galleryColumns : undefined, carouselSlides: Array.isArray(item.carouselSlides) ? item.carouselSlides.map((slide: Partial) => ({ id: String(slide?.id || createLocalId()), imageUrl: String(slide?.imageUrl ?? ''), caption: String(slide?.caption ?? ''), })) : undefined, iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '', carouselPrevIconUrl: typeof item.carouselPrevIconUrl === 'string' ? item.carouselPrevIconUrl : '', carouselNextIconUrl: typeof item.carouselNextIconUrl === 'string' ? item.carouselNextIconUrl : '', // Carousel button positions carouselPrevX: typeof item.carouselPrevX === 'number' ? item.carouselPrevX : undefined, carouselPrevY: typeof item.carouselPrevY === 'number' ? item.carouselPrevY : undefined, carouselNextX: typeof item.carouselNextX === 'number' ? item.carouselNextX : undefined, carouselNextY: typeof item.carouselNextY === 'number' ? item.carouselNextY : undefined, // Carousel button dimensions carouselPrevWidth: typeof item.carouselPrevWidth === 'string' ? item.carouselPrevWidth : undefined, carouselPrevHeight: typeof item.carouselPrevHeight === 'string' ? item.carouselPrevHeight : undefined, carouselNextWidth: typeof item.carouselNextWidth === 'string' ? item.carouselNextWidth : undefined, carouselNextHeight: typeof item.carouselNextHeight === 'string' ? item.carouselNextHeight : undefined, // Gallery Carousel Settings galleryCarouselPrevIconUrl: typeof item.galleryCarouselPrevIconUrl === 'string' ? item.galleryCarouselPrevIconUrl : '', galleryCarouselNextIconUrl: typeof item.galleryCarouselNextIconUrl === 'string' ? item.galleryCarouselNextIconUrl : '', galleryCarouselBackIconUrl: typeof item.galleryCarouselBackIconUrl === 'string' ? item.galleryCarouselBackIconUrl : '', galleryCarouselBackLabel: typeof item.galleryCarouselBackLabel === 'string' ? item.galleryCarouselBackLabel : '', galleryCarouselPrevX: typeof item.galleryCarouselPrevX === 'number' ? item.galleryCarouselPrevX : undefined, galleryCarouselPrevY: typeof item.galleryCarouselPrevY === 'number' ? item.galleryCarouselPrevY : undefined, galleryCarouselNextX: typeof item.galleryCarouselNextX === 'number' ? item.galleryCarouselNextX : undefined, galleryCarouselNextY: typeof item.galleryCarouselNextY === 'number' ? item.galleryCarouselNextY : undefined, galleryCarouselBackX: typeof item.galleryCarouselBackX === 'number' ? item.galleryCarouselBackX : undefined, galleryCarouselBackY: typeof item.galleryCarouselBackY === 'number' ? item.galleryCarouselBackY : undefined, galleryCarouselPrevWidth: typeof item.galleryCarouselPrevWidth === 'string' ? item.galleryCarouselPrevWidth : undefined, galleryCarouselPrevHeight: typeof item.galleryCarouselPrevHeight === 'string' ? item.galleryCarouselPrevHeight : undefined, galleryCarouselNextWidth: typeof item.galleryCarouselNextWidth === 'string' ? item.galleryCarouselNextWidth : undefined, galleryCarouselNextHeight: typeof item.galleryCarouselNextHeight === 'string' ? item.galleryCarouselNextHeight : undefined, galleryCarouselBackWidth: typeof item.galleryCarouselBackWidth === 'string' ? item.galleryCarouselBackWidth : undefined, galleryCarouselBackHeight: typeof item.galleryCarouselBackHeight === 'string' ? item.galleryCarouselBackHeight : undefined, tooltipTitle: typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '', tooltipText: typeof item.tooltipText === 'string' ? item.tooltipText : '', descriptionTitle: typeof item.descriptionTitle === 'string' ? item.descriptionTitle : '', descriptionText: typeof item.descriptionText === 'string' ? item.descriptionText : '', navLabel: typeof item.navLabel === 'string' ? item.navLabel : '', navType: item.navType === 'back' || item.navType === 'forward' ? item.navType : isNavigationElementType(elementType) ? getNavigationButtonKind( elementType as NavigationElementType, ) : undefined, // Support both targetPageSlug (new) and targetPageId (legacy) targetPageSlug: typeof item.targetPageSlug === 'string' ? item.targetPageSlug : '', targetPageId: typeof item.targetPageId === 'string' ? item.targetPageId : '', transitionVideoUrl: typeof item.transitionVideoUrl === 'string' ? item.transitionVideoUrl : '', transitionReverseMode: item.transitionReverseMode === 'separate_video' ? 'separate_video' : ('auto_reverse' as const), reverseVideoUrl: typeof item.reverseVideoUrl === 'string' ? item.reverseVideoUrl : '', transitionDurationSec: item.transitionDurationSec ? Number(item.transitionDurationSec) : undefined, mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '', mediaAutoplay: typeof item.mediaAutoplay === 'boolean' ? item.mediaAutoplay : true, mediaLoop: typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true, mediaMuted: typeof item.mediaMuted === 'boolean' ? item.mediaMuted : isVideoPlayerElementType(item.type), }; return mergeElementWithDefaults( normalizedElement, uiElementDefaultsByType[elementType], { preferElementValues: true }, ); }) : []; setElements(normalizedElements); setSelectedMenuItem('none'); const currentSelectedId = selectedElementIdRef.current; if (!normalizedElements.length) { clearSelection(); } else if ( elementIdFromRoute && normalizedElements.some((element) => element.id === elementIdFromRoute) ) { selectElement(elementIdFromRoute); } else if ( currentSelectedId && !normalizedElements.some((element) => element.id === currentSelectedId) ) { // Current selection no longer valid clearSelection(); } // If current selection is still valid, do nothing (keep current) // Update consolidated background state (replaces 8 separate setters) updateBackgroundFromPage(activePage); }, [ activePage, elementIdFromRoute, uiElementDefaultsByType, clearSelection, selectElement, setElements, updateBackgroundFromPage, ]); // Separate effect for initial background loading (matches RuntimePresentation pattern) // This effect ONLY handles initial page load when backgrounds are empty. // switchToPage handles all subsequent navigation by calling navNavigateToPage directly. // Keeping this separate prevents race conditions where state updates trigger // this effect before activePageId has been updated via applyPageSelection. useEffect(() => { if (!activePage || lastInitializedPageIdRef.current === activePage.id) return; // Only initialize when backgrounds are EMPTY (initial load) if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) { lastInitializedPageIdRef.current = activePage.id; navNavigateToPage({ id: activePage.id, background_image_url: activePage.background_image_url, background_video_url: activePage.background_video_url, background_audio_url: activePage.background_audio_url, }); } }, [ activePage, navCurrentBgImageUrl, navCurrentBgVideoUrl, navNavigateToPage, ]); useEffect(() => { if (allowedNavigationTypes.length !== 1) return; const forcedType = allowedNavigationTypes[0]; setElements((prev) => { let hasChanges = false; const nextElements = prev.map((element) => { if ( !isNavigationElementType(element.type) || element.type === forcedType ) return element; hasChanges = true; return normalizeNavigationType(element, forcedType); }); return hasChanges ? nextElements : prev; }); }, [allowedNavigationTypes, normalizeNavigationType, setElements]); // Element drag is now handled by useCanvasElementDrag hook useEffect(() => { if (isConstructorEditMode) return; cancelElementDrag(); clearSelection(); setSelectedMenuItem('none'); }, [isConstructorEditMode, cancelElementDrag, clearSelection]); // Outside click detection to clear element/menu selection // Ignore clicks on menu to allow menu item selection useOutsideClick({ containerRef: elementEditorRef, ignoreRefs: [toolbarRef], ignoreDataAttribute: 'data-constructor-element-id', selectedValue: selectedElementId, onOutsideClick: useCallback(() => { clearSelection(); setSelectedMenuItem('none'); }, [clearSelection]), enabled: isConstructorEditMode && (!!selectedElementId || selectedMenuItem !== 'none'), }); // Thin wrappers for hook functions (handle additional state like selectedMenuItem) const selectElementForEdit = useCallback( (elementId: string) => { selectElement(elementId); // Note: setSelectedMenuItem('none') is handled by onElementSelected callback }, [selectElement], ); const selectMenuItemForEdit = useCallback( (item: EditorMenuItem) => { clearSelection(); setSelectedMenuItem(item); }, [clearSelection], ); // createPage, saveConstructor, saveToStage are now provided by useConstructorPageActions hook const onElementMouseDown = (event: React.MouseEvent, elementId: string) => { if (!isConstructorEditMode) return; const currentElement = elements.find((item) => item.id === elementId); if (!currentElement) return; // Select the element for editing selectElementForEdit(elementId); // Start drag with current position onElementDragStart( event, elementId, currentElement.xPercent, currentElement.yPercent, ); }; // openTransitionPreviewForElement is now provided by useTransitionPreview hook const openTransitionPreview = (direction: 'forward' | 'back') => { if ( !selectedElement || (selectedElement.type !== 'navigation_next' && selectedElement.type !== 'navigation_prev') ) { return; } openTransitionPreviewForElement(selectedElement, direction); }; const onCanvasElementClick = (element: CanvasElement) => { if (!isConstructorEditMode) { if (isNavigationElementType(element.type)) { // Disable navigation while transition is playing or buffering if (transitionPreview || isTransitionBuffering) { return; } if (element.navDisabled) { return; } // Cancel any pending fade to prevent stale fade state across navigations resetFadeIn(); // Use shared navigation helpers const direction = getNavigationDirection(element); // Get navigation context from hook for history-based back navigation const navContext = getNavigationContext(); // Pass history context for history-based back navigation const navTarget = resolveNavigationTarget(element, pages, navContext); if (!navTarget) { // Back buttons always use history, forward buttons use target page if (isBackNavigation(element)) { if (!navContext.previousPageId) { setErrorMessage( 'No previous page in history. Navigate to another page first.', ); } else { setErrorMessage( 'Previous page not found. It may have been deleted.', ); } } else { setErrorMessage( 'No target page configured for this navigation button.', ); } return; } // For back navigation, use transition from navTarget (the forward element that brought us here) // For forward navigation, use the element's own transition settings const transitionSource = isBackNavigation(element) ? { type: element.type, transitionVideoUrl: navTarget.transitionVideoUrl, transitionReverseMode: navTarget.transitionReverseMode, reverseVideoUrl: navTarget.reverseVideoUrl, } : element; // Extract element transition settings for CSS-based transitions // For back navigation, use navTarget's settings (the forward element that brought us here) // For forward navigation, use the clicked element's settings const elementTransitionSource = isBackNavigation(element) ? navTarget : element; const elementSettings = extractElementTransitionSettings( elementTransitionSource, ); // Use flushSync to ensure state is updated synchronously before transition starts // Without this, React's async state batching causes the transition to use OLD settings flushSync(() => { setCurrentElementTransitionSettings(elementSettings); }); // Note: Background ready state is reset atomically by navigateToPage/switchToPage // Check if transition can be played using shared helper const canPlayTransition = hasPlayableTransition(transitionSource, direction); // Check if video is already cached (use video even on slow network if cached) const transitionUrl = transitionSource.transitionVideoUrl; const isTransitionCached = transitionUrl && preloadOrchestrator.getReadyBlobUrl(transitionUrl); // Use video if: has playable transition AND (cached OR good network) const useVideoTransition = canPlayTransition && (isTransitionCached || shouldUseVideoTransitions); if (!useVideoTransition) { closeTransitionPreview(); // Log when skipping video due to slow network if (canPlayTransition && !shouldUseVideoTransitions && transitionUrl) { logger.info( '[NAVIGATION] Skipping video transition due to slow network, downloading in background', { effectiveType: networkInfo.effectiveType, downlink: networkInfo.downlink, rtt: networkInfo.rtt, }, ); // Start background download of transition video for future use (low priority) downloadManager.addJob({ assetId: `transition-bg-${transitionUrl}`, projectId: 'transition-preload', url: resolveAssetPlaybackUrl(transitionUrl), filename: transitionUrl.split('/').pop() || 'transition.mp4', variantType: 'original', assetType: 'video', priority: 10, // Low priority - background preload storageKey: transitionUrl, }); } // Use switchToPage which resolves blob URLs via navigation state machine (reduces flash) // Pass isBack flag for proper history management switchToPage(navTarget.page, navTarget.isBack).then(() => { clearSelection(); setSelectedMenuItem('none'); setErrorMessage(''); }); return; } // Signal navigation state machine that video transition is starting // This sets phase to 'transitioning' so spinner shows during buffering logger.info('[TRANSITION-START] 🚀 Starting transition', { targetPageId: navTarget.pageId, direction, isBack: navTarget.isBack, transitionVideoUrl: transitionSource.transitionVideoUrl?.slice(-60), reverseVideoUrl: transitionSource.reverseVideoUrl?.slice(-60), }); // Pause background downloads to give transition video exclusive bandwidth downloadManager.pauseAll(); startTransition(navTarget.pageId, navTarget.isBack); openPreviewWithTarget(transitionSource, direction, navTarget.pageId); } return; } selectElementForEdit(element.id); }; // Handler for gallery card clicks const handleGalleryCardClick = useCallback( (element: CanvasElement, cardIndex: number) => { if (element.galleryCards && element.galleryCards.length > 0) { setActiveGalleryCarousel({ elementId: element.id, initialIndex: cardIndex, }); } }, [], ); // Handler for gallery carousel button position changes (constructor only) const handleGalleryCarouselButtonPositionChange = useCallback( (button: 'prev' | 'next' | 'back', x: number, y: number) => { if (!activeGalleryCarousel) return; const positionPatch = button === 'prev' ? { galleryCarouselPrevX: x, galleryCarouselPrevY: y } : button === 'next' ? { galleryCarouselNextX: x, galleryCarouselNextY: y } : { galleryCarouselBackX: x, galleryCarouselBackY: y }; // Update the element by explicit ID (not by selection) // because the gallery element may not be selected when the carousel is open setElements((prev) => prev.map((el) => el.id === activeGalleryCarousel.elementId ? { ...el, ...positionPatch } : el, ), ); // No need to update activeGalleryCarousel - it stores only elementId // and the element lookup is done via activeGalleryCarouselElement useMemo }, [activeGalleryCarousel, setElements], ); // Handler for carousel element button position changes (constructor only) const handleCarouselButtonPositionChange = useCallback( (elementId: string, button: 'prev' | 'next', x: number, y: number) => { const positionPatch = button === 'prev' ? { carouselPrevX: x, carouselPrevY: y } : { carouselNextX: x, carouselNextY: y }; setElements((prev) => prev.map((el) => el.id === elementId ? { ...el, ...positionPatch } : el, ), ); }, [setElements], ); const isElementVisibleOnCanvas = (element: CanvasElement) => isElementVisibleAtTime( canvasElapsedSec, element.appearDelaySec, element.appearDurationSec, ); // Check if a single element's icon is ready (used for individual element visibility) const isElementIconReady = useCallback( (element: CanvasElement) => { const isPreloadableIconElement = (isNavigationElementType(element.type) || isTooltipElementType(element.type) || isDescriptionElementType(element.type)) && Boolean(element.iconUrl); if (!isPreloadableIconElement) return true; const playbackUrl = resolveAssetPlaybackUrl(element.iconUrl); if (!playbackUrl) return true; return Boolean(preloadedIconUrlMap[playbackUrl]); }, [preloadedIconUrlMap], ); // Check if ALL element icons are ready - used to show all elements together // This prevents staggered element appearance where some elements show before others const areAllElementIconsReady = useMemo(() => { return elements.every((element) => isElementIconReady(element)); }, [elements, isElementIconReady]); // URL resolver that uses preloaded blob URLs when available // Depends on readyUrlsVersion to re-render when blob URLs become ready after preload const resolveUrlWithBlob = useCallback( (url: string | undefined): string => { if (!url) return ''; // Try to get blob URL from preload orchestrator (instant display) // Check storage key first (most reliable), then resolved URL const blobUrl = preloadOrchestrator.getReadyBlobUrl(url) || preloadOrchestrator.getReadyBlobUrl(resolveAssetPlaybackUrl(url)); if (blobUrl) return blobUrl; // Fall back to standard resolution return resolveAssetPlaybackUrl(url); }, [preloadOrchestrator, preloadOrchestrator.readyUrlsVersion], ); const canvasBackgroundStyle: React.CSSProperties = {}; // Unified background URL resolution // Edit mode: use local storage paths (for immediate editing feedback) // Interact mode: use nav state values (blob URLs from preload cache) const backgroundImageSrc = useMemo(() => { if (isConstructorEditMode && backgroundImageUrl) { return resolveUrlWithBlob(backgroundImageUrl); } return navCurrentBgImageUrl; }, [ isConstructorEditMode, backgroundImageUrl, navCurrentBgImageUrl, resolveUrlWithBlob, ]); const backgroundVideoSrc = useMemo(() => { if (isConstructorEditMode && backgroundVideoUrl) { return resolveUrlWithBlob(backgroundVideoUrl); } return navCurrentBgVideoUrl; }, [ isConstructorEditMode, backgroundVideoUrl, navCurrentBgVideoUrl, resolveUrlWithBlob, ]); const backgroundAudioSrc = useMemo(() => { if (isConstructorEditMode && backgroundAudioUrl) { return resolveUrlWithBlob(backgroundAudioUrl); } return navCurrentBgAudioUrl; }, [ isConstructorEditMode, backgroundAudioUrl, navCurrentBgAudioUrl, resolveUrlWithBlob, ]); const hasEditorSelection = isConstructorEditMode && (Boolean(selectedElement) || selectedMenuItem !== 'none'); const editorTitle = selectedMenuItem === 'background_image' ? 'Background image' : selectedMenuItem === 'background_video' ? 'Background video' : selectedMenuItem === 'background_audio' ? 'Background audio' : selectedMenuItem === 'create_transition' ? 'Create transition' : selectedElement?.label || 'Element editor'; // Background image is rendered by CanvasBackground component (same as runtime) // No CSS background-image needed on canvas div // Duration notes for UI display const durationNotes = useMemo( () => ({ backgroundVideo: backgroundVideoDurationNote, backgroundAudio: backgroundAudioDurationNote, selectedMedia: selectedMediaDurationNote, selectedTransition: selectedTransitionDurationNote, newTransition: newTransitionDurationNote, }), [ backgroundVideoDurationNote, backgroundAudioDurationNote, selectedMediaDurationNote, selectedTransitionDurationNote, newTransitionDurationNote, ], ); // Transition creation state for context const transitionCreationState = useMemo( () => ({ name: newTransitionName, videoUrl: newTransitionVideoUrl, supportsReverse: newTransitionSupportsReverse, isCreating: isCreatingTransition, setName: setNewTransitionName, setVideoUrl: setNewTransitionVideoUrl, setSupportsReverse: setNewTransitionSupportsReverse, create: () => createTransition({ name: newTransitionName, videoUrl: newTransitionVideoUrl, supportsReverse: newTransitionSupportsReverse, durationSec: getDuration(newTransitionVideoUrl), }), }), [ newTransitionName, newTransitionVideoUrl, newTransitionSupportsReverse, isCreatingTransition, createTransition, getDuration, ], ); // Build context value for ConstructorProvider // This allows child components to access constructor state without prop drilling const constructorContextValue: ConstructorContextValue = useMemo( () => ({ // Project state projectId, // Page state pages, activePageId, activePage, setActivePageId, // Background state pageBackground, setPageBackground, updateBackgroundFromPage, // Background convenience setters setBackgroundImageUrl, setBackgroundVideoUrl, setBackgroundAudioUrl, setBackgroundVideoSettings, // Element state elements, setElements, selectedElementId, selectedElement, selectElement, clearSelection, updateElement: (id: string, patch: Partial) => { setElements((prev) => prev.map((el) => (el.id === id ? { ...el, ...patch } : el)), ); }, removeElement: (id: string) => { setElements((prev) => prev.filter((el) => el.id !== id)); if (selectedElementId === id) { clearSelection(); } }, updateSelectedElement, removeSelectedElement, // Menu state selectedMenuItem, setSelectedMenuItem, isMenuOpen, setIsMenuOpen, // Editor state elementEditorTab, setElementEditorTab, // Assets assets, isLoadingAssets: isDataLoading, // Asset options (derived from assets) assetOptions, // Gallery/Carousel operations galleryCards, galleryInfoSpans, carouselSlides, // Duration resolver getDuration, // Duration notes durationNotes, // Transition preview onPreviewTransition: openTransitionPreview, // Transition creation transitionCreation: transitionCreationState, // Navigation settings allowedNavigationTypes, normalizeNavigationType, // Actions save: saveConstructor, isSaving, }), [ projectId, pages, activePageId, activePage, pageBackground, setPageBackground, updateBackgroundFromPage, setBackgroundImageUrl, setBackgroundVideoUrl, setBackgroundAudioUrl, setBackgroundVideoSettings, elements, setElements, selectedElementId, selectedElement, selectElement, clearSelection, updateSelectedElement, removeSelectedElement, selectedMenuItem, isMenuOpen, elementEditorTab, assets, isDataLoading, assetOptions, galleryCards, galleryInfoSpans, carouselSlides, getDuration, durationNotes, openTransitionPreview, transitionCreationState, allowedNavigationTypes, normalizeNavigationType, saveConstructor, isSaving, ], ); return ( {getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}

{projectName || 'Loading project...'}

{errorMessage ? (

{errorMessage}

) : null} {successMessage ? (

{successMessage}

) : null} {pages.length > 0 && isElementEditMode && (
)}
{pages.length > 0 && !isElementEditMode && ( { const page = pages.find((p) => p.id === pageId); if (page) switchToPage(page); }} interactionMode={constructorInteractionMode} onModeChange={setConstructorInteractionMode} onSelectMenuItem={selectMenuItemForEdit} allowedNavigationTypes={allowedNavigationTypes} onAddElement={addElement} onCreatePage={createPage} isCreatingPage={isCreatingPage} onSave={saveConstructor} onSaveToStage={handleSaveToStage} isSaving={isSaving} isSavingToStage={isSavingToStage} lastSavedAt={lastProjectSaveAt} lastSavedToStageAt={lastSavedToStage} projectId={projectId} onExit={() => router.push( projectId ? `/projects/${projectId}` : '/projects/projects-list', ) } /> )} {/* Canvas container: z-[46] creates stacking context above carousel (z-10 bg, z-45 controls) portaled to body */}
{/* Safari Black Flash Prevention (video transitions only): Persistent snapshot layer shown ONLY during video transitions. z-[1] keeps it behind backgrounds (z-5) but above the black container. */} {lastKnownBgUrl && isSafari() && (transitionPreview || pendingTransitionComplete) && (
)} {/* Background wrapper - z-5 keeps it BELOW carousel slide (z-10). Previous background overlay shows during loading. Black overlay for fade effect is rendered separately at z-[100]. */}
{/* Page loading spinner - from unified navigation state machine. navShowSpinner is true when: - Phase is 'preparing', 'loading_bg', 'transition_done', OR - Video transition is active but buffering Additionally for constructor: show while element icons are loading. Only show in Interact mode - Edit mode doesn't need loading indicators. Skip when video transition overlay is active - it has its own spinner. */} {!isConstructorEditMode && !transitionPreview && (navShowSpinner || (navShowElements && !areAllElementIconsReady)) && ( )} {/* Elements container - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45). UI controls (z-50) remain on top. No fade animation - elements switch instantly behind the black overlay. Shows when phase is 'idle' or 'fading_in' (navShowElements) AND all element icons are preloaded. This ensures all elements appear together (no staggered appearance). Exception: Edit mode always shows elements for editing (no icon preload wait). */} {(isConstructorEditMode || (navShowElements && areAllElementIconsReady)) && (
{!isLoading && pages.length === 0 ? (
) : ( elements.map((element) => { // Icon preloading is now handled at container level (areAllElementIconsReady) // Here we only check visibility based on appear delay/duration timing const shouldRender = selectedElementId === element.id || isElementVisibleOnCanvas(element); if (!shouldRender) return null; return ( onCanvasElementClick(element)} onMouseDown={(event) => onElementMouseDown(event, element.id) } resolveUrl={resolveUrlWithBlob} onGalleryCardClick={(cardIndex) => handleGalleryCardClick(element, cardIndex) } onCarouselButtonPositionChange={(button, x, y) => handleCarouselButtonPositionChange( element.id, button, x, y, ) } letterboxStyles={letterboxStyles} pageTransitionSettings={transitionSettings} preloadCache={{ getReadyBlob: preloadOrchestrator.getReadyBlob, getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, }} /> ); }) )}
)} {/* Overlay for fade-through-color transition - z-[100] is ABOVE elements (z-[46]). This covers the elements during page transition to hide the instant switch. Only rendered for 'fade' type. */}
{/* ElementEditorPanel now uses ConstructorContext for all state */} {pages.length > 0 && hasEditorSelection && ( setIsEditorCollapsed((prev) => !prev)} onDragStart={onElementEditorDragStart} title={editorTitle} /> )}
{/* Gallery Carousel Overlay */} {activeGalleryCarousel && activeGalleryCarouselElement && ( setActiveGalleryCarousel(null)} resolveUrl={resolveUrlWithBlob} prevIconUrl={activeGalleryCarouselElement.galleryCarouselPrevIconUrl} nextIconUrl={activeGalleryCarouselElement.galleryCarouselNextIconUrl} backIconUrl={activeGalleryCarouselElement.galleryCarouselBackIconUrl} backLabel={ activeGalleryCarouselElement.galleryCarouselBackLabel || 'BACK' } prevX={activeGalleryCarouselElement.galleryCarouselPrevX} prevY={activeGalleryCarouselElement.galleryCarouselPrevY} nextX={activeGalleryCarouselElement.galleryCarouselNextX} nextY={activeGalleryCarouselElement.galleryCarouselNextY} backX={activeGalleryCarouselElement.galleryCarouselBackX} backY={activeGalleryCarouselElement.galleryCarouselBackY} prevWidth={activeGalleryCarouselElement.galleryCarouselPrevWidth} prevHeight={activeGalleryCarouselElement.galleryCarouselPrevHeight} nextWidth={activeGalleryCarouselElement.galleryCarouselNextWidth} nextHeight={activeGalleryCarouselElement.galleryCarouselNextHeight} backWidth={activeGalleryCarouselElement.galleryCarouselBackWidth} backHeight={activeGalleryCarouselElement.galleryCarouselBackHeight} letterboxStyles={letterboxStyles} isEditMode={isConstructorEditMode} onButtonPositionChange={handleGalleryCarouselButtonPositionChange} pageTransitionSettings={transitionSettings} galleryElement={activeGalleryCarouselElement} /> )} ); }; ConstructorPage.getLayout = function getLayout(page: ReactElement) { return {page}; }; export default ConstructorPage;