fixed adaptivity issue for transitions, added fades for navigation between pages without transitions

This commit is contained in:
Dmitri 2026-04-13 09:57:02 +04:00
parent ad9c788b21
commit d55453a42d
9 changed files with 279 additions and 238 deletions

View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" /> /// <reference path="./build/types/routes.d.ts" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

View File

@ -15,8 +15,10 @@ interface CanvasBackgroundProps {
backgroundVideoUrl?: string; backgroundVideoUrl?: string;
backgroundAudioUrl?: string; backgroundAudioUrl?: string;
previousBgImageUrl?: string; previousBgImageUrl?: string;
previousBgVideoUrl?: string;
isSwitching?: boolean; isSwitching?: boolean;
isNewBgReady?: boolean; isNewBgReady?: boolean;
isFadingIn?: boolean;
onBackgroundReady?: () => void; onBackgroundReady?: () => void;
// Video playback settings // Video playback settings
videoAutoplay?: boolean; videoAutoplay?: boolean;
@ -31,8 +33,10 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
backgroundVideoUrl, backgroundVideoUrl,
backgroundAudioUrl, backgroundAudioUrl,
previousBgImageUrl, previousBgImageUrl,
previousBgVideoUrl,
isSwitching = false, isSwitching = false,
isNewBgReady = false, isNewBgReady = false,
isFadingIn = false,
onBackgroundReady, onBackgroundReady,
videoAutoplay = true, videoAutoplay = true,
videoLoop = true, videoLoop = true,
@ -94,10 +98,12 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
</div> </div>
)} )}
{/* Previous background overlay - shows during page switch until new bg is ready */} {/* Previous background overlays - show during loading AND crossfade.
{previousBgImageUrl && isSwitching && !isNewBgReady && ( Uses CSS animation for fade-out effect during crossfade.
z-0 keeps them BELOW new backgrounds (z-1). */}
{previousBgImageUrl && (isFadingIn || (isSwitching && !isNewBgReady)) && (
<div <div
className='pointer-events-none absolute inset-0 z-10' className={`pointer-events-none absolute inset-0 z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}
style={{ style={{
backgroundImage: `url("${previousBgImageUrl}")`, backgroundImage: `url("${previousBgImageUrl}")`,
backgroundSize: 'contain', backgroundSize: 'contain',
@ -106,6 +112,16 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
}} }}
/> />
)} )}
{previousBgVideoUrl && (isFadingIn || (isSwitching && !isNewBgReady)) && (
<video
className={`absolute inset-0 z-0 h-full w-full object-contain pointer-events-none ${isFadingIn ? 'animate-crossfade-out' : ''}`}
src={previousBgVideoUrl}
autoPlay
loop
muted
playsInline
/>
)}
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) */} {/* Background video - z-1 keeps it below backdrop blur layer (z-5) */}
{backgroundVideoUrl && ( {backgroundVideoUrl && (

View File

@ -4,6 +4,9 @@
* Full-screen overlay for transition video preview. * Full-screen overlay for transition video preview.
* Designed to work with useTransitionPlayback hook which manages * Designed to work with useTransitionPlayback hook which manages
* video src and playback externally via the videoRef. * video src and playback externally via the videoRef.
*
* Supports letterbox mode to constrain transitions within canvas bounds,
* matching the behavior of background images and UI elements.
*/ */
import React from 'react'; import React from 'react';
@ -15,26 +18,53 @@ interface TransitionPreviewOverlayProps {
isActive: boolean; isActive: boolean;
/** Whether the video is currently buffering (used to hide video during load) */ /** Whether the video is currently buffering (used to hide video during load) */
isBuffering?: boolean; isBuffering?: boolean;
/** Letterbox styles from useCanvasScale - positions overlay within canvas bounds */
letterboxStyles?: React.CSSProperties;
/** Video object-fit mode (default: 'contain' to match backgrounds) */
videoFit?: 'contain' | 'cover';
/** Additional opacity value for fade-out effects (0-1) */
opacity?: number;
} }
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
videoRef, videoRef,
isActive, isActive,
isBuffering = false, isBuffering = false,
letterboxStyles,
videoFit = 'contain',
opacity,
}) => { }) => {
if (!isActive) return null; if (!isActive) return null;
// Video opacity: 0 while buffering, 1 otherwise
const videoOpacity = isBuffering ? 0 : 1;
// Container opacity: controlled by parent for fade-out effects
const containerOpacity = opacity ?? 1;
return ( return (
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'> // Outer: full viewport with black background (letterbox bars)
<video // No fade transition - transition video itself is the effect
ref={videoRef} <div
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear' className='fixed inset-0 z-50 overflow-hidden pointer-events-none bg-black'
style={{ opacity: isBuffering ? 0 : 1 }} style={{ opacity: containerOpacity }}
muted >
playsInline {/* Inner: respects letterbox dimensions when provided */}
preload='auto' <div
disablePictureInPicture className='overflow-hidden'
/> style={letterboxStyles || { position: 'absolute', inset: 0 }}
>
<video
ref={videoRef}
className={`absolute inset-0 h-full w-full transition-opacity duration-300 ease-linear ${
videoFit === 'cover' ? 'object-cover' : 'object-contain'
}`}
style={{ opacity: videoOpacity }}
muted
playsInline
preload='auto'
disablePictureInPicture
/>
</div>
</div> </div>
); );
}; };

View File

@ -22,6 +22,7 @@ import BaseButton from './BaseButton';
import CardBox from './CardBox'; import CardBox from './CardBox';
import { OfflineToggle } from './Offline/OfflineToggle'; import { OfflineToggle } from './Offline/OfflineToggle';
import RuntimeElement from './RuntimeElement'; import RuntimeElement from './RuntimeElement';
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay'; import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
import { BackdropPortalProvider } from './BackdropPortal'; import { BackdropPortalProvider } from './BackdropPortal';
import { RotatePrompt } from './RotatePrompt'; import { RotatePrompt } from './RotatePrompt';
@ -226,12 +227,12 @@ export default function RuntimePresentation({
}, },
}); });
// Use shared background transition hook for fade-out and fade-in effects // Use shared background transition hook for crossfade effects
const { const {
isOverlayFadingOut, isOverlayFadingOut,
resetFadeOut, resetFadeOut,
isFadingIn, isFadingIn,
elementsOpacity, onFadeInAnimationEnd,
resetFadeIn, resetFadeIn,
} = useBackgroundTransition({ } = useBackgroundTransition({
pageSwitch, pageSwitch,
@ -349,10 +350,10 @@ export default function RuntimePresentation({
isReverse: isBack, isReverse: isBack,
}); });
} else { } else {
// Direct navigation - use shared hook for smooth transition // Direct navigation with crossfade effect:
// Reset fade-in state to start fresh // useBackgroundTransition detects switching and applies animation classes
resetFadeIn(); // - New page gets animate-crossfade-in (0 → 1)
// Previous background stays visible until new one is ready // - Previous background gets animate-crossfade-out (1 → 0)
setIsBackgroundReady(false); setIsBackgroundReady(false);
// Mark this page as initialized to prevent redundant effect calls // Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId; lastInitializedPageIdRef.current = targetPageId;
@ -534,65 +535,17 @@ export default function RuntimePresentation({
style={{ style={{
...cssVars, ...cssVars,
...letterboxStyles, ...letterboxStyles,
backgroundImage: backgroundImageUrl
? `url("${backgroundImageUrl}")`
: undefined,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}} }}
> >
<BackdropPortalProvider> <BackdropPortalProvider>
{/* Background image element - z-1 keeps it below backdrop blur (z-5). {/* Previous background overlays - show during loading AND crossfade.
CSS backgroundImage provides instant display. Uses CSS animation for fade-out effect.
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */} Cleared by useBackgroundTransition after fade completes. */}
{backgroundImageUrl && !backgroundVideoUrl && (
<div className='absolute inset-0 z-1 pointer-events-none'>
{backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
className='absolute inset-0 w-full h-full object-contain'
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
) : (
<Image
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
fill
sizes='100vw'
className='object-contain'
priority
unoptimized
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
)}
</div>
)}
{/* Previous background overlay - shows during direct navigation until new bg is ready */}
{pageSwitch.previousBgImageUrl && {pageSwitch.previousBgImageUrl &&
pageSwitch.isSwitching && (isFadingIn ||
!pageSwitch.isNewBgReady && ( (pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && (
<div <div
className='absolute inset-0 pointer-events-none z-10' className={`absolute inset-0 pointer-events-none z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}
style={{ style={{
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`, backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
backgroundSize: 'contain', backgroundSize: 'contain',
@ -601,43 +554,101 @@ export default function RuntimePresentation({
}} }}
/> />
)} )}
{pageSwitch.previousBgVideoUrl &&
{/* Background video - z-1 keeps it below backdrop blur (z-5) */} (isFadingIn ||
{backgroundVideoUrl && ( (pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && (
<video <video
ref={bgVideoRef} className={`absolute inset-0 h-full w-full object-contain pointer-events-none z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}
key={backgroundVideoUrl} src={pageSwitch.previousBgVideoUrl}
className='absolute inset-0 z-1 h-full w-full object-contain' autoPlay
src={backgroundVideoUrl} loop
autoPlay={videoAutoplay} muted
loop={useNativeLoop} playsInline
muted={videoMuted}
playsInline
/>
)}
{/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */}
<div
className='absolute inset-0 z-40'
style={{
opacity: elementsOpacity,
transition: isFadingIn
? `opacity ${CANVAS_CONFIG.pageTransition.fadeInDurationMs}ms ${CANVAS_CONFIG.pageTransition.easing}`
: 'none',
}}
>
{pageElements.map((element: CanvasElement) => (
<RuntimeElement
key={element.id}
element={element}
onClick={() => handleElementClick(element)}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex)
}
/> />
))} )}
{/* New page content wrapper - fades in for non-transition navigation.
z-1 ensures it's above previous backgrounds (z-0) during fade.
onAnimationEnd resets isFadingIn when CSS animation completes. */}
<div
data-testid='page-content-wrapper'
className={`absolute inset-0 z-1 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
onAnimationEnd={onFadeInAnimationEnd}
>
{/* Background image element - z-1 keeps it below backdrop blur (z-5).
CSS backgroundImage provides instant display.
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */}
{backgroundImageUrl && !backgroundVideoUrl && (
<div className='absolute inset-0 z-1 pointer-events-none'>
{backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
className='absolute inset-0 w-full h-full object-contain'
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
) : (
<Image
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
fill
sizes='100vw'
className='object-contain'
priority
unoptimized
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
)}
</div>
)}
{/* Background video - z-1 keeps it below backdrop blur (z-5) */}
{backgroundVideoUrl && (
<video
ref={bgVideoRef}
key={backgroundVideoUrl}
className='absolute inset-0 z-1 h-full w-full object-contain'
src={backgroundVideoUrl}
autoPlay={videoAutoplay}
loop={useNativeLoop}
muted={videoMuted}
playsInline
/>
)}
{/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */}
<div className='absolute inset-0 z-40'>
{pageElements.map((element: CanvasElement) => (
<RuntimeElement
key={element.id}
element={element}
onClick={() => handleElementClick(element)}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex)
}
/>
))}
</div>
</div> </div>
{/* End new page content wrapper */}
{/* Controls: Offline toggle and Fullscreen button */} {/* Controls: Offline toggle and Fullscreen button */}
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'> <div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
@ -661,24 +672,13 @@ export default function RuntimePresentation({
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */} {/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */} {/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
{transitionPreview && ( {transitionPreview && (
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'> <TransitionPreviewOverlay
<video videoRef={transitionVideoRef}
ref={transitionVideoRef} isActive={true}
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear' isBuffering={transitionPhase === 'preparing' || isBuffering}
style={{ letterboxStyles={letterboxStyles}
opacity: opacity={isOverlayFadingOut ? 0 : 1}
transitionPhase === 'preparing' || />
isBuffering ||
isOverlayFadingOut
? 0
: 1,
}}
muted
playsInline
preload='auto'
disablePictureInPicture
/>
</div>
)} )}
{/* Gallery Carousel Overlay */} {/* Gallery Carousel Overlay */}

View File

@ -43,14 +43,10 @@ export const CANVAS_CONFIG = {
// Page transition effects // Page transition effects
pageTransition: { pageTransition: {
/**
* Fade-in duration for non-transition navigation (ms).
* Applied when switching pages without a transition video.
*/
fadeInDurationMs: 500,
/** /**
* Fade-out duration for transition video overlay (ms). * Fade-out duration for transition video overlay (ms).
* Applied after transition video finishes playing. * Applied after transition video finishes playing.
* Note: Crossfade duration is controlled by CSS in main.css (.animate-crossfade-in/out)
*/ */
fadeOutDurationMs: 300, fadeOutDurationMs: 300,
/** /**

View File

@ -34,6 +34,34 @@
@apply bg-transparent border border-blue-600 text-blue-600 !important; @apply bg-transparent border border-blue-600 text-blue-600 !important;
} }
/* Page crossfade animation keyframes */
@keyframes page-crossfade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes page-crossfade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* Crossfade animation classes */
.animate-crossfade-in {
animation: page-crossfade-in 500ms ease-out forwards;
}
.animate-crossfade-out {
animation: page-crossfade-out 500ms ease-out forwards;
}
/* Element appear animation keyframes */ /* Element appear animation keyframes */
@keyframes element-fade-in { @keyframes element-fade-in {
from { from {

View File

@ -14,19 +14,20 @@
* 2. Simple mode (constructor): Direct navigation clearing + optional fade-in * 2. Simple mode (constructor): Direct navigation clearing + optional fade-in
*/ */
import { useEffect, useState, useCallback, useRef } from 'react'; import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from 'react';
import { CANVAS_CONFIG } from '../config/canvas.config'; import { CANVAS_CONFIG } from '../config/canvas.config';
/** /**
* Fade-out duration from config * Fade-out duration from config (for transition video overlay)
*/ */
const FADE_OUT_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeOutDurationMs; const FADE_OUT_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeOutDurationMs;
/**
* Fade-in duration from config
*/
const FADE_IN_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeInDurationMs;
/** /**
* Fade-out configuration (optional - for RuntimePresentation) * Fade-out configuration (optional - for RuntimePresentation)
*/ */
@ -47,8 +48,6 @@ export interface FadeOutConfig {
export interface FadeInConfig { export interface FadeInConfig {
/** Whether a transition video is currently active (disables fade-in) */ /** Whether a transition video is currently active (disables fade-in) */
hasActiveTransition: boolean; hasActiveTransition: boolean;
/** Optional duration override (uses config default if not provided) */
durationMs?: number;
} }
export interface UseBackgroundTransitionOptions { export interface UseBackgroundTransitionOptions {
@ -58,6 +57,7 @@ export interface UseBackgroundTransitionOptions {
isSwitching: boolean; isSwitching: boolean;
isNewBgReady: boolean; isNewBgReady: boolean;
previousBgImageUrl: string; previousBgImageUrl: string;
previousBgVideoUrl: string;
}; };
/** Optional fade-out configuration (for RuntimePresentation) */ /** Optional fade-out configuration (for RuntimePresentation) */
fadeOut?: FadeOutConfig; fadeOut?: FadeOutConfig;
@ -70,11 +70,11 @@ export interface UseBackgroundTransitionResult {
isOverlayFadingOut: boolean; isOverlayFadingOut: boolean;
/** Reset the fade-out state (call before starting a new transition) */ /** Reset the fade-out state (call before starting a new transition) */
resetFadeOut: () => void; resetFadeOut: () => void;
/** Whether page content is currently fading in */ /** Whether page content is currently fading (crossfade in progress) */
isFadingIn: boolean; isFadingIn: boolean;
/** Opacity value for elements container (0 during fade, 1 when complete) */ /** Handler to call when fade-in animation ends (pass to onAnimationEnd) */
elementsOpacity: number; onFadeInAnimationEnd: () => void;
/** Reset fade-in state before starting new navigation */ /** Reset fade-in state (for cleanup or cancellation) */
resetFadeIn: () => void; resetFadeIn: () => void;
} }
@ -83,7 +83,7 @@ export interface UseBackgroundTransitionResult {
* *
* @example * @example
* // Full mode with fade-out and fade-in (RuntimePresentation) * // Full mode with fade-out and fade-in (RuntimePresentation)
* const { isOverlayFadingOut, resetFadeOut, isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({ * const { isOverlayFadingOut, resetFadeOut, isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
* pageSwitch, * pageSwitch,
* fadeOut: { * fadeOut: {
* pendingTransitionComplete, * pendingTransitionComplete,
@ -99,6 +99,12 @@ export interface UseBackgroundTransitionResult {
* }, * },
* }); * });
* *
* // In JSX:
* <div
* className={isFadingIn ? 'animate-crossfade-in' : ''}
* onAnimationEnd={onFadeInAnimationEnd}
* >
*
* @example * @example
* // Simple mode - direct navigation only (constructor) * // Simple mode - direct navigation only (constructor)
* useBackgroundTransition({ pageSwitch }); * useBackgroundTransition({ pageSwitch });
@ -109,15 +115,10 @@ export function useBackgroundTransition({
fadeIn, fadeIn,
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult { }: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false); const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
// Fade-in state
const [isFadingIn, setIsFadingIn] = useState(false); const [isFadingIn, setIsFadingIn] = useState(false);
const [elementsOpacity, setElementsOpacity] = useState(1);
// Track previous isSwitching state to detect transition start // Track previous isSwitching state to detect transition start
const wasSwitchingRef = useRef(false); const wasSwitchingRef = useRef(false);
// Track timer for cleanup
const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** /**
* Reset fade-out state before starting a new transition. * Reset fade-out state before starting a new transition.
@ -128,16 +129,18 @@ export function useBackgroundTransition({
}, []); }, []);
/** /**
* Reset fade-in state before starting new navigation. * Reset fade-in state (for cleanup or cancellation).
* Clears any in-progress fade animation.
*/ */
const resetFadeIn = useCallback(() => { const resetFadeIn = useCallback(() => {
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
setIsFadingIn(false); setIsFadingIn(false);
setElementsOpacity(1); }, []);
/**
* Handler for onAnimationEnd event.
* Called when CSS animation completes - CSS is the source of truth for duration.
*/
const onFadeInAnimationEnd = useCallback(() => {
setIsFadingIn(false);
}, []); }, []);
/** /**
@ -187,92 +190,55 @@ export function useBackgroundTransition({
}, [fadeOut, isOverlayFadingOut, pageSwitch]); }, [fadeOut, isOverlayFadingOut, pageSwitch]);
/** /**
* Effect: Clear previous background overlay when new background is ready (direct navigation). * Effect: Clear previous background overlay after fade completes (direct navigation).
* *
* This handles the case when navigating without a transition video. * The previous background stays visible during the entire fade animation,
* The previous background stays visible until the new one is ready to paint. * providing a smooth crossfade effect. Only cleared after fade ends.
*/ */
useEffect(() => { useEffect(() => {
if ( if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) {
pageSwitch.isSwitching && // Fade is complete - clear the previous background overlay
pageSwitch.isNewBgReady && // This also resets isSwitching state so next navigation triggers fade-in
pageSwitch.previousBgImageUrl
) {
// New background is ready - clear the previous background overlay
pageSwitch.clearPreviousBackground(); pageSwitch.clearPreviousBackground();
} }
}, [ }, [
pageSwitch.isSwitching, pageSwitch.isSwitching,
pageSwitch.isNewBgReady, pageSwitch.isNewBgReady,
pageSwitch.previousBgImageUrl,
pageSwitch.clearPreviousBackground, pageSwitch.clearPreviousBackground,
isFadingIn,
]); ]);
/** /**
* Effect: Fade-in page content on non-transition navigation. * Layout effect: Set up crossfade BEFORE browser paints when switching starts.
* useLayoutEffect runs synchronously after DOM mutations but before paint,
* preventing any flash of new content at full opacity.
* *
* Trigger: pageSwitch.isSwitching becomes true AND no active transition * IMPORTANT: Skip this for transitions - transition video IS the effect.
*
* Sequence:
* 1. Navigation starts (isSwitching: false true)
* 2. No transition video active
* 3. Set elementsOpacity = 0
* 4. Use double RAF to ensure paint before animation
* 5. Set elementsOpacity = 1 (CSS animates)
* 6. After duration, set isFadingIn = false
*/ */
useEffect(() => { useLayoutEffect(() => {
if (!fadeIn) { if (!fadeIn) {
wasSwitchingRef.current = pageSwitch.isSwitching; wasSwitchingRef.current = pageSwitch.isSwitching;
return; return;
} }
const { hasActiveTransition, durationMs = FADE_IN_DURATION_MS } = fadeIn; const { hasActiveTransition } = fadeIn;
const justStartedSwitching = const justStartedSwitching =
pageSwitch.isSwitching && !wasSwitchingRef.current; pageSwitch.isSwitching && !wasSwitchingRef.current;
wasSwitchingRef.current = pageSwitch.isSwitching; wasSwitchingRef.current = pageSwitch.isSwitching;
// Start fade-in when: // Only start crossfade for NON-transition navigation
// - Just started switching (transition from false to true) // Transitions use video overlay - no fade needed
// - No active transition video
if (justStartedSwitching && !hasActiveTransition) { if (justStartedSwitching && !hasActiveTransition) {
setIsFadingIn(true); setIsFadingIn(true);
setElementsOpacity(0);
// Double RAF ensures opacity:0 is painted before transition starts
// (Same pattern as usePageSwitch.ts:396-397)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setElementsOpacity(1);
});
});
// Clear any existing timer
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
}
// Mark fade as complete after duration
fadeInTimerRef.current = setTimeout(() => {
setIsFadingIn(false);
fadeInTimerRef.current = null;
}, durationMs);
} }
// Cleanup on unmount
return () => {
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
};
}, [pageSwitch.isSwitching, fadeIn]); }, [pageSwitch.isSwitching, fadeIn]);
return { return {
isOverlayFadingOut, isOverlayFadingOut,
resetFadeOut, resetFadeOut,
isFadingIn, isFadingIn,
elementsOpacity, onFadeInAnimationEnd,
resetFadeIn, resetFadeIn,
}; };
} }

View File

@ -63,6 +63,8 @@ export interface UsePageSwitchResult {
currentBgAudioUrl: string; currentBgAudioUrl: string;
/** Previous background image URL (for overlay) */ /** Previous background image URL (for overlay) */
previousBgImageUrl: string; previousBgImageUrl: string;
/** Previous background video URL (for overlay during fade) */
previousBgVideoUrl: string;
/** Whether we're in the middle of a page switch */ /** Whether we're in the middle of a page switch */
isSwitching: boolean; isSwitching: boolean;
/** Whether the new background is ready to display */ /** Whether the new background is ready to display */
@ -198,6 +200,10 @@ export function usePageSwitch(
const previousBgImageUrlRef = useRef(''); const previousBgImageUrlRef = useRef('');
previousBgImageUrlRef.current = previousBgImageUrl; previousBgImageUrlRef.current = previousBgImageUrl;
const [previousBgVideoUrl, setPreviousBgVideoUrl] = useState('');
const previousBgVideoUrlRef = useRef('');
previousBgVideoUrlRef.current = previousBgVideoUrl;
// Transition state // Transition state
const [isSwitching, setIsSwitching] = useState(false); const [isSwitching, setIsSwitching] = useState(false);
const [isNewBgReady, setIsNewBgReady] = useState(true); const [isNewBgReady, setIsNewBgReady] = useState(true);
@ -362,16 +368,20 @@ export function usePageSwitch(
setCurrentBgVideoUrl(''); setCurrentBgVideoUrl('');
setCurrentBgAudioUrl(''); setCurrentBgAudioUrl('');
setPreviousBgImageUrl(''); setPreviousBgImageUrl('');
setPreviousBgVideoUrl('');
setIsSwitching(false); setIsSwitching(false);
setIsNewBgReady(true); setIsNewBgReady(true);
onSwitched?.(); onSwitched?.();
return; return;
} }
// Save current image as previous for overlay (use ref to avoid dependency) // Save current backgrounds as previous for overlay (use refs to avoid dependencies)
if (currentBgImageUrlRef.current) { if (currentBgImageUrlRef.current) {
setPreviousBgImageUrl(currentBgImageUrlRef.current); setPreviousBgImageUrl(currentBgImageUrlRef.current);
} }
if (currentBgVideoUrlRef.current) {
setPreviousBgVideoUrl(currentBgVideoUrlRef.current);
}
setIsSwitching(true); setIsSwitching(true);
setIsNewBgReady(false); setIsNewBgReady(false);
@ -433,16 +443,21 @@ export function usePageSwitch(
}, []); }, []);
/** /**
* Clear the previous background overlay * Clear the previous background overlay (both image and video)
*/ */
const clearPreviousBackground = useCallback(() => { const clearPreviousBackground = useCallback(() => {
const prevUrl = previousBgImageUrlRef.current; const prevImageUrl = previousBgImageUrlRef.current;
const prevVideoUrl = previousBgVideoUrlRef.current;
setPreviousBgImageUrl(''); setPreviousBgImageUrl('');
setPreviousBgVideoUrl('');
setIsSwitching(false); setIsSwitching(false);
// Revoke the previous blob URL after clearing // Revoke the previous blob URLs after clearing
if (prevUrl) { if (prevImageUrl) {
revokeBlobUrl(prevUrl); revokeBlobUrl(prevImageUrl);
}
if (prevVideoUrl) {
revokeBlobUrl(prevVideoUrl);
} }
}, [revokeBlobUrl]); }, [revokeBlobUrl]);
@ -451,6 +466,7 @@ export function usePageSwitch(
currentBgVideoUrl, currentBgVideoUrl,
currentBgAudioUrl, currentBgAudioUrl,
previousBgImageUrl, previousBgImageUrl,
previousBgVideoUrl,
isSwitching, isSwitching,
isNewBgReady, isNewBgReady,
switchToPage, switchToPage,

View File

@ -358,10 +358,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Destructure stable callback reference to avoid infinite loops in useEffect deps // Destructure stable callback reference to avoid infinite loops in useEffect deps
const pageSwitchToPage = pageSwitch.switchToPage; const pageSwitchToPage = pageSwitch.switchToPage;
// Use shared background transition hook for direct navigation clearing and fade-in // Use shared background transition hook for direct navigation clearing and crossfade
// (No fade-out needed in constructor - transitions complete immediately) // Crossfade starts automatically when new background is ready
// NOTE: Must be defined before switchToPage callback which uses resetFadeIn const { isFadingIn } = useBackgroundTransition({
const { isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
pageSwitch, pageSwitch,
fadeIn: { fadeIn: {
hasActiveTransition: Boolean(transitionPreview), hasActiveTransition: Boolean(transitionPreview),
@ -374,9 +373,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// isBack parameter indicates this is a back navigation (pops history instead of pushing) // isBack parameter indicates this is a back navigation (pops history instead of pushing)
const switchToPage = useCallback( const switchToPage = useCallback(
async (page: TourPage | null, isBack = false) => { async (page: TourPage | null, isBack = false) => {
// Reset fade-in state to start fresh
resetFadeIn();
// Mark this page as initialized to prevent redundant effect calls // Mark this page as initialized to prevent redundant effect calls
if (page) { if (page) {
lastInitializedPageIdRef.current = page.id; lastInitializedPageIdRef.current = page.id;
@ -386,6 +382,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
updateBackgroundFromPage(page); updateBackgroundFromPage(page);
// Use hook to resolve and set blob URLs for display // Use hook to resolve and set blob URLs for display
// Fade starts automatically when new background is ready (crossfade effect)
await pageSwitchToPage( await pageSwitchToPage(
page page
? { ? {
@ -403,12 +400,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}, },
); );
}, },
[ [pageSwitchToPage, updateBackgroundFromPage, applyPageSelection],
pageSwitchToPage,
updateBackgroundFromPage,
applyPageSelection,
resetFadeIn,
],
); );
const { isBuffering: isReverseBuffering } = useTransitionPlayback({ const { isBuffering: isReverseBuffering } = useTransitionPlayback({
@ -1466,8 +1458,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundVideoUrl={backgroundVideoSrc} backgroundVideoUrl={backgroundVideoSrc}
backgroundAudioUrl={backgroundAudioSrc} backgroundAudioUrl={backgroundAudioSrc}
previousBgImageUrl={pageSwitch.previousBgImageUrl} previousBgImageUrl={pageSwitch.previousBgImageUrl}
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
isSwitching={pageSwitch.isSwitching} isSwitching={pageSwitch.isSwitching}
isNewBgReady={pageSwitch.isNewBgReady} isNewBgReady={pageSwitch.isNewBgReady}
isFadingIn={isFadingIn}
onBackgroundReady={() => pageSwitch.markBackgroundReady()} onBackgroundReady={() => pageSwitch.markBackgroundReady()}
videoAutoplay={backgroundVideoAutoplay} videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop} videoLoop={backgroundVideoLoop}
@ -1478,13 +1472,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
{/* Elements container - z-10 ensures they appear above backdrop layer */} {/* Elements container - z-10 ensures they appear above backdrop layer */}
<div <div
className='absolute inset-0 z-10' className={`absolute inset-0 z-10 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
style={{
opacity: elementsOpacity,
transition: isFadingIn
? `opacity ${CANVAS_CONFIG.pageTransition.fadeInDurationMs}ms ${CANVAS_CONFIG.pageTransition.easing}`
: 'none',
}}
> >
{isLoading ? ( {isLoading ? (
<div className='absolute inset-0 flex items-center justify-center'> <div className='absolute inset-0 flex items-center justify-center'>
@ -1590,6 +1578,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
videoRef={transitionVideoRef} videoRef={transitionVideoRef}
isActive={Boolean(transitionPreview)} isActive={Boolean(transitionPreview)}
isBuffering={isReverseBuffering} isBuffering={isReverseBuffering}
letterboxStyles={letterboxStyles}
/> />
{/* Gallery Carousel Overlay */} {/* Gallery Carousel Overlay */}