39948-vm/frontend/src/components/RuntimePresentation.tsx

801 lines
30 KiB
TypeScript

/**
* RuntimePresentation Component
*
* Renders a presentation for a specific project and environment.
* Used by /p/[projectSlug] and /p/[projectSlug]/stage routes.
*/
import Head from 'next/head';
import React, {
ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import CardBox from './CardBox';
import RuntimeControls from './Runtime/RuntimeControls';
import RuntimeElement from './RuntimeElement';
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
import { BackdropPortalProvider } from './BackdropPortal';
import { RotatePrompt } from './RotatePrompt';
import CanvasBackground from './Constructor/CanvasBackground';
import { useCanvasScale } from '../hooks/useCanvasScale';
import { CANVAS_CONFIG } from '../config/canvas.config';
import LayoutGuest from '../layouts/Guest';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { usePageDataLoader } from '../hooks/usePageDataLoader';
import { useProjectAssets } from '../hooks/useProjectAssets';
import { usePageNavigation } from '../hooks/usePageNavigation';
import {
extractPageLinksOnly,
extractElementsForPages,
} from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
import { useBackgroundUrls } from '../hooks/useBackgroundUrls';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { isSafari, scheduleAfterPaint } from '../lib/browserUtils';
import { logger } from '../lib/logger';
import {
resolveNavigationTarget,
isTransitionBlocking,
isBackNavigation,
isNavigationType,
} from '../lib/navigationHelpers';
import type { TransitionPhase } from '../types/presentation';
import type { CanvasElement } from '../types/constructor';
interface RuntimePresentationProps {
projectSlug: string;
environment: 'stage' | 'production';
}
export default function RuntimePresentation({
projectSlug,
environment,
}: RuntimePresentationProps) {
// Use shared hook for loading project and pages data
const { project, pages, isLoading, error, initialPageId } = usePageDataLoader(
{
projectSlug,
environment,
apiHeaders: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment,
},
},
);
// Resolve project assets (favicon, og_image, logo) to presigned URLs
const { faviconUrl, ogImageUrl } = useProjectAssets(project);
// Page navigation with history tracking via shared hook
const {
currentPageId: selectedPageId,
pageHistory,
applyPageSelection,
getNavigationContext,
} = usePageNavigation({
pages,
defaultPageId: initialPageId || undefined,
trackHistory: true,
});
// Get current page for design dimensions (presentations use page dimensions, not project)
const currentPage = pages.find((p) => p.id === selectedPageId);
// Canvas scale for responsive UI elements and letterbox mode
// Uses page's design dimensions (saved at constructor save time) for presentation isolation
const { cssVars, letterboxStyles, isPortrait, showRotatePrompt } =
useCanvasScale({
designWidth: currentPage?.design_width ?? undefined,
designHeight: currentPage?.design_height ?? undefined,
});
const [transitionPreview, setTransitionPreview] = useState<{
targetPageId: string;
videoUrl: string;
storageKey: string;
isBack: boolean;
reverseVideoUrl?: string;
} | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
// Track when transition video has completed but we're waiting for background to load
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
element: CanvasElement;
initialIndex: number;
} | null>(null);
// Safari Black Flash Prevention (video transitions only):
// Track the last successfully displayed background to use as a "snapshot" layer.
// Only shown during video transitions to prevent black flashes.
// NOT shown during crossfade navigation (would interfere with smooth animation).
const [lastKnownBgUrl, setLastKnownBgUrl] = useState<string>('');
const transitionVideoRef = useRef<HTMLVideoElement>(null);
const lastInitializedPageIdRef = useRef<string | null>(null);
// Note: Initial page selection is handled by usePageNavigation hook via defaultPageId
// Phase 1: Extract pageLinks from ALL pages (needed for navigation graph)
// This is lightweight - only extracts navigation structure, not asset URLs
const pageLinks = useMemo(() => {
const links = extractPageLinksOnly(pages);
if (links.length > 0) {
logger.info('[PRELOAD] Extracted page links', {
count: links.length,
links: links.map((link) => ({
from: link.from_pageId?.slice(-8),
to: link.to_pageId?.slice(-8),
hasTransition: !!link.transition?.video_url,
})),
});
}
return links;
}, [pages]);
// Phase 2: Extract elements only for current + neighbor pages (progressive)
// This avoids parsing ui_schema_json for all pages upfront
const preloadElements = useMemo(() => {
if (!selectedPageId || pages.length === 0) return [];
// Build simple neighbor set from pageLinks
const neighborIds = new Set<string>();
neighborIds.add(selectedPageId); // Current page
pageLinks.forEach((link) => {
if (link.from_pageId === selectedPageId && link.to_pageId) {
neighborIds.add(link.to_pageId); // Direct neighbors
}
});
// Extract elements only for current + neighbors
const elements = extractElementsForPages(pages, Array.from(neighborIds));
logger.info('[PRELOAD] Extracted elements for pages', {
currentPage: selectedPageId.slice(-8),
pageCount: neighborIds.size,
elementCount: elements.length,
});
return elements;
}, [pages, pageLinks, selectedPageId]);
// Initialize preload orchestrator with transformed data
const preloadOrchestrator = usePreloadOrchestrator({
pages,
pageLinks,
elements: preloadElements,
currentPageId: selectedPageId,
pageHistory,
enabled: !isLoading && !error,
});
// Initialize page switch hook for smooth background transitions
const pageSwitch = usePageSwitch({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
});
// Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern)
const { isBuffering, phase: transitionPhase } = useTransitionPlayback({
videoRef: transitionVideoRef,
transition: transitionPreview
? {
videoUrl: transitionPreview.videoUrl,
storageKey: transitionPreview.storageKey,
reverseMode: transitionPreview.reverseVideoUrl ? 'separate' : 'none',
reverseVideoUrl: transitionPreview.reverseVideoUrl,
targetPageId: transitionPreview.targetPageId,
displayName: 'Transition',
isBack: transitionPreview.isBack,
}
: null,
onComplete: async (targetPageId, isBack) => {
if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId);
// Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId;
// Use shared hook to resolve blob URLs and switch page
await pageSwitch.switchToPage(targetPage, () => {
// Use applyPageSelection for proper history management (pops on back)
applyPageSelection(targetPageId, isBack ?? false);
});
setIsBackgroundReady(false);
// Video transition completed - last frame shows new page background
// Signal that we're waiting for background to load before removing overlay
// Overlay will be removed instantly (no fade) when isBackgroundReady becomes true
setPendingTransitionComplete(true);
} else {
// No target page - clean up and remove overlay
const video = transitionVideoRef.current;
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingTransitionComplete(false);
}
},
features: {
useBlobUrl: true,
// Don't pre-decode images in the hook - we handle it via overlay:
// Overlay shows last transition frame while new page background loads behind it
preDecodeImages: false,
},
preload: {
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl,
},
});
// Use shared background transition hook for crossfade effects
// NOTE: fadeOut config is NOT used for video transitions.
// Video transitions end instantly (last frame = new page, then overlay removed).
// fadeIn is used for non-video navigation (crossfade 500ms).
// hasActiveTransition includes pendingTransitionComplete to prevent crossfade
// during the video-to-background handoff phase.
const { isFadingIn, resetFadeIn } = useBackgroundTransition({
pageSwitch,
fadeIn: {
hasActiveTransition:
Boolean(transitionPreview) || pendingTransitionComplete,
},
});
const toggleFullscreen = useCallback(async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
setIsFullscreen(true);
} else {
await document.exitFullscreen();
setIsFullscreen(false);
}
} catch (err) {
logger.error(
'Fullscreen toggle failed:',
err instanceof Error ? err : { error: err },
);
}
}, []);
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(Boolean(document.fullscreenElement));
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () =>
document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const selectedPage = useMemo(
() => pages.find((p) => p.id === selectedPageId) || null,
[pages, selectedPageId],
);
const pageElements = useMemo(() => {
if (!selectedPage) return [];
try {
const uiSchema =
typeof selectedPage.ui_schema_json === 'string'
? JSON.parse(selectedPage.ui_schema_json)
: selectedPage.ui_schema_json;
return Array.isArray(uiSchema?.elements) ? uiSchema.elements : [];
} catch {
return [];
}
}, [selectedPage]);
// Set initial backgrounds when page first loads (before preload cache is populated)
// The condition ensures this only runs once on initial load when backgrounds are empty.
// After that, navigateToPage handles all subsequent navigation explicitly.
useEffect(() => {
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
// Only initialize when backgrounds are empty (initial load)
// navigateToPage handles subsequent navigation by calling switchToPage directly
if (!pageSwitch.currentBgImageUrl && !pageSwitch.currentBgVideoUrl) {
lastInitializedPageIdRef.current = selectedPage.id;
pageSwitch.switchToPage(selectedPage);
}
}
}, [
selectedPage,
pageSwitch.currentBgImageUrl,
pageSwitch.currentBgVideoUrl,
pageSwitch.switchToPage,
]);
// Handle background ready state for pages without any background
useEffect(() => {
// Only mark ready immediately if there's no background media at all.
// For pages with image or video, CanvasBackground will call onBackgroundReady
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback).
if (
!selectedPage?.background_image_url &&
!selectedPage?.background_video_url
) {
setIsBackgroundReady(true);
}
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
// Video transition overlay removal - instant (no fade) when background is ready
// Uses scheduleAfterPaint to ensure browser has painted the new background before removing overlay
// Note: Double RAF doesn't work in Safari (nested RAFs can execute in same frame)
// CRITICAL: Must wait for BOTH isBackgroundReady AND pageSwitch.isNewBgReady
// - isBackgroundReady: RuntimePresentation state (image onLoad or immediate for video pages)
// - pageSwitch.isNewBgReady: Controls PreviousBackgroundOverlay visibility
// If we only check isBackgroundReady, PreviousBackgroundOverlay may still be showing
useEffect(() => {
if (
pendingTransitionComplete &&
isBackgroundReady &&
pageSwitch.isNewBgReady
) {
// Wait for paint cycle to complete before removing overlay
// scheduleAfterPaint handles Safari's RAF quirks automatically
scheduleAfterPaint(() => {
// CRITICAL: Remove overlay from DOM FIRST, then clear video src
// If we clear src before removing overlay, Safari shows black frame
// because video.removeAttribute('src') immediately clears the frame
setTransitionPreview(null);
setPendingTransitionComplete(false);
// Clear previous background now that transition is complete
// This resets isSwitching state for next navigation
pageSwitch.clearPreviousBackground();
// Clear video src AFTER overlay is removed from DOM
// Use another scheduleAfterPaint to ensure React has unmounted the overlay
scheduleAfterPaint(() => {
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
});
});
}
}, [
pendingTransitionComplete,
isBackgroundReady,
pageSwitch.isNewBgReady,
pageSwitch.clearPreviousBackground,
]);
// Safari Black Flash Prevention (video transitions only):
// Update lastKnownBgUrl whenever we have a valid background image.
// This ensures snapshot is always ready before transitions start.
useEffect(() => {
if (pageSwitch.currentBgImageUrl) {
setLastKnownBgUrl(pageSwitch.currentBgImageUrl);
}
}, [pageSwitch.currentBgImageUrl]);
const navigateToPage = useCallback(
async (
targetPageId: string,
transitionVideoUrl?: string,
isBack = false,
reverseVideoUrl?: string,
) => {
const targetPage = pages.find((p) => p.id === targetPageId);
if (!targetPage) return;
if (transitionVideoUrl) {
// Reset states from previous transition/navigation
resetFadeIn();
setPendingTransitionComplete(false);
// Play transition using useTransitionPlayback hook
setTransitionPreview({
targetPageId,
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl),
storageKey: transitionVideoUrl, // Raw storage path for cache lookup
isBack,
reverseVideoUrl: reverseVideoUrl
? resolveAssetPlaybackUrl(reverseVideoUrl)
: undefined,
});
} else {
// Direct navigation with crossfade effect:
// useBackgroundTransition detects switching and applies animation classes
// - New page gets animate-crossfade-in (0 → 1)
// - Previous background gets animate-crossfade-out (1 → 0)
setIsBackgroundReady(false);
// Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId;
await pageSwitch.switchToPage(targetPage, () => {
// Use applyPageSelection for proper history management (pops on back)
applyPageSelection(targetPageId, isBack);
});
}
},
[pages, pageSwitch, resetFadeIn, applyPageSelection],
);
// Compute whether all neighbor backgrounds are ready for instant navigation
const areNeighborBackgroundsReady =
preloadOrchestrator?.areNeighborBackgroundsReady ?? true;
// Compute disabled state for forward navigation elements
// DISABLED: Allow navigation even if neighbors not preloaded
const isForwardNavDisabled = false && !areNeighborBackgroundsReady;
const handleElementClick = useCallback(
(element: CanvasElement) => {
// Block navigation while transition is actively playing or buffering
if (
isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)
) {
return;
}
// DISABLED: Block forward navigation if neighbor backgrounds not yet preloaded
// Back navigation is always allowed (previous pages are already visited)
if (
false &&
isNavigationType(element.type) &&
!isBackNavigation(element) &&
!areNeighborBackgroundsReady
) {
logger.info('Navigation blocked - neighbors not preloaded');
return;
}
// Get navigation context from hook for history-based back navigation
const navContext = getNavigationContext();
// Use shared helper to resolve navigation target with history context
const navTarget = resolveNavigationTarget(element, pages, navContext);
// Debug: log element navigation data
logger.info('Element clicked', {
elementType: element.type,
targetPageSlug: element.targetPageSlug,
legacyTargetPageId: element.targetPageId,
resolvedTargetPageId: navTarget?.pageId,
transitionVideoUrl: element.transitionVideoUrl,
hasTransition: Boolean(element.transitionVideoUrl),
isBack: isBackNavigation(element),
previousPageId: navContext.previousPageId,
});
if (navTarget) {
navigateToPage(
navTarget.pageId,
navTarget.transitionVideoUrl,
navTarget.isBack,
navTarget.reverseVideoUrl,
);
}
},
[
navigateToPage,
pages,
transitionPhase,
isBuffering,
getNavigationContext,
areNeighborBackgroundsReady,
],
);
// Handler for gallery card clicks
const handleGalleryCardClick = useCallback(
(element: CanvasElement, cardIndex: number) => {
if (element.galleryCards?.length > 0) {
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
}
},
[],
);
// URL resolver that uses preloaded blob URLs when available (instant display)
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],
);
// Unified background URL resolution via shared hook (same as constructor)
// No localPaths needed since RuntimePresentation has no editing mode
const {
backgroundImageSrc: backgroundImageUrl,
backgroundVideoSrc: backgroundVideoUrl,
} = useBackgroundUrls({
pageSwitch,
resolveUrl: resolveUrlWithBlob,
});
// Background video playback settings from selected page
const videoAutoplay = selectedPage?.background_video_autoplay ?? true;
const videoLoop = selectedPage?.background_video_loop ?? true;
const videoMuted = selectedPage?.background_video_muted ?? true;
const videoStartTime =
selectedPage?.background_video_start_time != null
? parseFloat(String(selectedPage.background_video_start_time))
: null;
const videoEndTime =
selectedPage?.background_video_end_time != null
? parseFloat(String(selectedPage.background_video_end_time))
: null;
// Note: useBackgroundVideoPlayback is handled internally by CanvasBackground component
if (isLoading) {
return (
<div className='flex items-center justify-center min-h-screen bg-gray-900'>
<div className='text-white text-xl'>Loading presentation...</div>
</div>
);
}
if (error) {
return (
<div className='flex items-center justify-center min-h-screen bg-gray-900'>
<CardBox className='max-w-md'>
<h2 className='text-xl font-bold text-red-500 mb-4'>Error</h2>
<p className='text-gray-300'>{error}</p>
</CardBox>
</div>
);
}
return (
<>
{/* Rotate prompt for portrait orientation */}
<RotatePrompt show={showRotatePrompt && isPortrait} />
<Head>
<title>{project?.name || 'Presentation'}</title>
{faviconUrl && <link key='favicon' rel='icon' href={faviconUrl} />}
{ogImageUrl && (
<>
<meta key='og:image' property='og:image' content={ogImageUrl} />
<meta
key='twitter:image:src'
property='twitter:image:src'
content={ogImageUrl}
/>
</>
)}
{project?.name && (
<>
<meta key='og:title' property='og:title' content={project.name} />
<meta
key='twitter:title'
property='twitter:title'
content={project.name}
/>
</>
)}
{project?.description && (
<>
<meta
key='og:description'
property='og:description'
content={project.description}
/>
<meta
key='twitter:description'
property='twitter:description'
content={project.description}
/>
</>
)}
</Head>
{/* Outer container: full viewport with black background for letterbox bars */}
<div className='relative w-screen h-screen overflow-hidden bg-black'>
{/* Inner canvas: maintains aspect ratio centered in viewport.
z-[46] creates stacking context above carousel (z-10 bg, z-45 controls) portaled to body. */}
<div
className='relative z-[46] overflow-hidden'
style={{
...cssVars,
...letterboxStyles,
}}
>
<BackdropPortalProvider>
{/* Safari Black Flash Prevention (video transitions only):
Persistent snapshot layer shown ONLY during video transitions.
NOT shown during crossfade navigation (would interfere with animation).
z-[1] keeps it behind backgrounds (z-5) but above the black container. */}
{lastKnownBgUrl &&
isSafari() &&
(transitionPreview || pendingTransitionComplete) && (
<div
className='absolute inset-0 z-[1] pointer-events-none'
style={{
backgroundImage: `url("${lastKnownBgUrl}")`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
/>
)}
{/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10).
Fades in for non-transition navigation. Uses shared CanvasBackground component
for single source of truth with constructor (same transitions, same structure). */}
<div
data-testid='page-background-wrapper'
className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
>
<CanvasBackground
backgroundImageUrl={backgroundImageUrl}
backgroundVideoUrl={backgroundVideoUrl}
previousBgImageUrl={pageSwitch.previousBgImageUrl}
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
isSwitching={pageSwitch.isSwitching}
isNewBgReady={pageSwitch.isNewBgReady}
isFadingIn={isFadingIn}
onBackgroundReady={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
videoAutoplay={videoAutoplay}
videoLoop={videoLoop}
videoMuted={videoMuted}
videoStartTime={videoStartTime}
videoEndTime={videoEndTime}
videoStoragePath={selectedPage?.background_video_url}
/>
</div>
{/* End page background wrapper */}
{/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
UI controls (z-50) remain on top.
Fades in together with background. */}
<div
data-testid='page-elements-wrapper'
className={`absolute inset-0 z-[46] ${isFadingIn ? 'animate-crossfade-in' : ''}`}
>
{pageElements.map((element: CanvasElement) => (
<RuntimeElement
key={element.id}
element={element}
onClick={() => handleElementClick(element)}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex)
}
letterboxStyles={letterboxStyles}
isForwardNavDisabled={isForwardNavDisabled}
/>
))}
</div>
{/* End page elements wrapper */}
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* NO fade-out: video itself IS the transition (last frame = new page) */}
{/* Overlay removed instantly via setTransitionPreview(null) in onComplete */}
{transitionPreview && (
<TransitionPreviewOverlay
videoRef={transitionVideoRef}
isActive={true}
isBuffering={
// Hide overlay until video first frame is painted:
// - 'idle': React render cycle before hook effect runs
// - 'preparing': Video loading/buffering
// - isBuffering: Waiting for first frame paint (from hook)
transitionPhase === 'idle' ||
transitionPhase === 'preparing' ||
isBuffering
}
letterboxStyles={letterboxStyles}
opacity={1}
/>
)}
{/* Gallery Carousel Overlay */}
{activeGalleryCarousel && (
<GalleryCarouselOverlay
cards={activeGalleryCarousel.element.galleryCards || []}
initialIndex={activeGalleryCarousel.initialIndex}
onClose={() => setActiveGalleryCarousel(null)}
resolveUrl={resolveUrlWithBlob}
prevIconUrl={
activeGalleryCarousel.element.galleryCarouselPrevIconUrl
}
nextIconUrl={
activeGalleryCarousel.element.galleryCarouselNextIconUrl
}
backIconUrl={
activeGalleryCarousel.element.galleryCarouselBackIconUrl
}
backLabel={
activeGalleryCarousel.element.galleryCarouselBackLabel ||
'BACK'
}
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
backX={activeGalleryCarousel.element.galleryCarouselBackX}
backY={activeGalleryCarousel.element.galleryCarouselBackY}
prevWidth={
activeGalleryCarousel.element.galleryCarouselPrevWidth
}
prevHeight={
activeGalleryCarousel.element.galleryCarouselPrevHeight
}
nextWidth={
activeGalleryCarousel.element.galleryCarouselNextWidth
}
nextHeight={
activeGalleryCarousel.element.galleryCarouselNextHeight
}
backWidth={
activeGalleryCarousel.element.galleryCarouselBackWidth
}
backHeight={
activeGalleryCarousel.element.galleryCarouselBackHeight
}
letterboxStyles={letterboxStyles}
isEditMode={false}
/>
)}
</BackdropPortalProvider>
</div>
{/* End inner canvas container */}
{/* Controls: Offline toggle and Fullscreen button */}
{/* Positioned outside canvas to avoid scaling with canvas transform */}
<RuntimeControls
projectId={project?.id || null}
projectSlug={projectSlug}
projectName={project?.name}
pages={pages}
isFullscreen={isFullscreen}
toggleFullscreen={toggleFullscreen}
/>
{/* Toast notifications for offline download status */}
<ToastContainer
position='bottom-center'
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme='dark'
/>
</div>
</>
);
}
// Layout wrapper for standalone usage
RuntimePresentation.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};