271 lines
8.3 KiB
TypeScript
271 lines
8.3 KiB
TypeScript
/**
|
|
* useBackgroundTransition Hook
|
|
*
|
|
* Manages background transition effects when switching between pages.
|
|
* Handles crossfade animation for non-video navigation and
|
|
* coordinates with the page switch hook to clear previous backgrounds.
|
|
*
|
|
* This hook consolidates the background transition logic used by both
|
|
* RuntimePresentation and constructor.tsx.
|
|
*
|
|
* NOTE: Video transitions do NOT use any fades - the video itself IS the transition.
|
|
* Video last frame = new page background, then overlay is removed instantly.
|
|
*
|
|
* Two modes:
|
|
* 1. fadeIn mode: Crossfade animation for direct (non-video) page navigation
|
|
* 2. fadeOut mode: Legacy support - kept for backwards compatibility but not recommended
|
|
*/
|
|
|
|
import {
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useState,
|
|
useCallback,
|
|
useRef,
|
|
} from 'react';
|
|
import {
|
|
isSafari,
|
|
scheduleAfterPaintSafari,
|
|
getCrossfadeDuration,
|
|
} from '../lib/browserUtils';
|
|
|
|
/**
|
|
* Fade-out configuration (optional - for RuntimePresentation)
|
|
*/
|
|
export interface FadeOutConfig {
|
|
/** Whether a transition video has finished playing and is waiting for bg ready */
|
|
pendingTransitionComplete: boolean;
|
|
/** Whether the new background image is ready to display */
|
|
isBackgroundReady: boolean;
|
|
/** Ref to the transition video element for cleanup */
|
|
transitionVideoRef: React.RefObject<HTMLVideoElement | null>;
|
|
/** Callback to clear transition state after overlay is removed */
|
|
onTransitionCleanup: () => void;
|
|
}
|
|
|
|
/**
|
|
* Fade-in configuration (optional - for page content fade-in)
|
|
*/
|
|
export interface FadeInConfig {
|
|
/** Whether a transition video is currently active (disables fade-in) */
|
|
hasActiveTransition: boolean;
|
|
}
|
|
|
|
export interface UseBackgroundTransitionOptions {
|
|
/** Page switch hook instance for clearing previous background */
|
|
pageSwitch: {
|
|
clearPreviousBackground: () => void;
|
|
isSwitching: boolean;
|
|
isNewBgReady: boolean;
|
|
previousBgImageUrl: string;
|
|
previousBgVideoUrl: string;
|
|
};
|
|
/** Optional fade-out configuration (for RuntimePresentation) */
|
|
fadeOut?: FadeOutConfig;
|
|
/** Optional fade-in configuration for page content */
|
|
fadeIn?: FadeInConfig;
|
|
}
|
|
|
|
export interface UseBackgroundTransitionResult {
|
|
/** Whether the overlay is currently fading out */
|
|
isOverlayFadingOut: boolean;
|
|
/** Reset the fade-out state (call before starting a new transition) */
|
|
resetFadeOut: () => void;
|
|
/** Whether page content is currently fading (crossfade in progress) */
|
|
isFadingIn: boolean;
|
|
/** Handler to call when fade-in animation ends (pass to onAnimationEnd) */
|
|
onFadeInAnimationEnd: () => void;
|
|
/** Reset fade-in state (for cleanup or cancellation) */
|
|
resetFadeIn: () => void;
|
|
}
|
|
|
|
/**
|
|
* Hook for managing background transition effects.
|
|
*
|
|
* @example
|
|
* // Full mode with fade-out and fade-in (RuntimePresentation)
|
|
* const { isOverlayFadingOut, resetFadeOut, isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
|
|
* pageSwitch,
|
|
* fadeOut: {
|
|
* pendingTransitionComplete,
|
|
* isBackgroundReady,
|
|
* transitionVideoRef,
|
|
* onTransitionCleanup: () => {
|
|
* setTransitionPreview(null);
|
|
* setPendingTransitionComplete(false);
|
|
* },
|
|
* },
|
|
* fadeIn: {
|
|
* hasActiveTransition: Boolean(transitionPreview),
|
|
* },
|
|
* });
|
|
*
|
|
* // In JSX:
|
|
* <div
|
|
* className={isFadingIn ? 'animate-crossfade-in' : ''}
|
|
* onAnimationEnd={onFadeInAnimationEnd}
|
|
* >
|
|
*
|
|
* @example
|
|
* // Simple mode - direct navigation only (constructor)
|
|
* useBackgroundTransition({ pageSwitch });
|
|
*/
|
|
export function useBackgroundTransition({
|
|
pageSwitch,
|
|
fadeOut,
|
|
fadeIn,
|
|
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
|
|
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
|
|
const [isFadingIn, setIsFadingIn] = useState(false);
|
|
|
|
// Track previous isSwitching state to detect transition start
|
|
const wasSwitchingRef = useRef(false);
|
|
|
|
/**
|
|
* Reset fade-out state before starting a new transition.
|
|
* This prevents the fade-out effect from re-triggering when state resets.
|
|
*/
|
|
const resetFadeOut = useCallback(() => {
|
|
setIsOverlayFadingOut(false);
|
|
}, []);
|
|
|
|
/**
|
|
* Reset fade-in state (for cleanup or cancellation).
|
|
*/
|
|
const resetFadeIn = useCallback(() => {
|
|
setIsFadingIn(false);
|
|
}, []);
|
|
|
|
/**
|
|
* Handler for onAnimationEnd event.
|
|
* Called when CSS animation completes - CSS is the source of truth for duration.
|
|
*/
|
|
const onFadeInAnimationEnd = useCallback(() => {
|
|
setIsFadingIn(false);
|
|
}, []);
|
|
|
|
/**
|
|
* Effect: Fade out and remove transition overlay when background is ready.
|
|
* Only runs when fadeOut config is provided.
|
|
*
|
|
* Sequence:
|
|
* 1. Transition video finishes playing (pendingTransitionComplete = true)
|
|
* 2. New background image loads (isBackgroundReady = true)
|
|
* 3. Safari: Wait extra frame to ensure background is painted
|
|
* 4. Start fade-out animation (isOverlayFadingOut = true)
|
|
* 5. After fade completes, clean up video and clear transition state
|
|
*/
|
|
useEffect(() => {
|
|
if (!fadeOut) return;
|
|
|
|
const {
|
|
pendingTransitionComplete,
|
|
isBackgroundReady,
|
|
transitionVideoRef,
|
|
onTransitionCleanup,
|
|
} = fadeOut;
|
|
|
|
if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) {
|
|
// Safari Black Flash Prevention:
|
|
// Wait an extra frame in Safari to ensure the new background is truly painted
|
|
// before starting the fade-out animation. This prevents showing black between
|
|
// the transition video and the new page content.
|
|
const startFadeOut = () => {
|
|
// Start fade-out animation
|
|
setIsOverlayFadingOut(true);
|
|
};
|
|
|
|
// Safari: verify paint completion with extra frame wait
|
|
if (isSafari()) {
|
|
scheduleAfterPaintSafari(startFadeOut);
|
|
} else {
|
|
startFadeOut();
|
|
}
|
|
}
|
|
}, [fadeOut, isOverlayFadingOut]);
|
|
|
|
/**
|
|
* Effect: Complete fade-out and cleanup after animation duration.
|
|
* Separated from the start effect to ensure proper cleanup timing.
|
|
*/
|
|
useEffect(() => {
|
|
if (!fadeOut || !isOverlayFadingOut) return;
|
|
|
|
const { transitionVideoRef, onTransitionCleanup } = fadeOut;
|
|
|
|
// After fade completes, remove the overlay
|
|
// Duration is read from CSS variable for consistency
|
|
const fadeTimer = setTimeout(() => {
|
|
const video = transitionVideoRef.current;
|
|
if (video) {
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
}
|
|
|
|
// Clear previous background from shared hook
|
|
pageSwitch.clearPreviousBackground();
|
|
|
|
// Notify caller to clear transition state
|
|
onTransitionCleanup();
|
|
|
|
// Reset fade-out state
|
|
setIsOverlayFadingOut(false);
|
|
}, getCrossfadeDuration());
|
|
|
|
return () => clearTimeout(fadeTimer);
|
|
}, [fadeOut, isOverlayFadingOut, pageSwitch]);
|
|
|
|
/**
|
|
* Effect: Clear previous background overlay after fade completes (direct navigation).
|
|
*
|
|
* The previous background stays visible during the entire fade animation,
|
|
* providing a smooth crossfade effect. Only cleared after fade ends.
|
|
*/
|
|
useEffect(() => {
|
|
if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) {
|
|
// Fade is complete - clear the previous background overlay
|
|
// This also resets isSwitching state so next navigation triggers fade-in
|
|
pageSwitch.clearPreviousBackground();
|
|
}
|
|
}, [
|
|
pageSwitch.isSwitching,
|
|
pageSwitch.isNewBgReady,
|
|
pageSwitch.clearPreviousBackground,
|
|
isFadingIn,
|
|
]);
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* IMPORTANT: Skip this for transitions - transition video IS the effect.
|
|
*/
|
|
useLayoutEffect(() => {
|
|
if (!fadeIn) {
|
|
wasSwitchingRef.current = pageSwitch.isSwitching;
|
|
return;
|
|
}
|
|
|
|
const { hasActiveTransition } = fadeIn;
|
|
const justStartedSwitching =
|
|
pageSwitch.isSwitching && !wasSwitchingRef.current;
|
|
|
|
wasSwitchingRef.current = pageSwitch.isSwitching;
|
|
|
|
// Only start crossfade for NON-transition navigation
|
|
// Transitions use video overlay - no fade needed
|
|
if (justStartedSwitching && !hasActiveTransition) {
|
|
setIsFadingIn(true);
|
|
}
|
|
}, [pageSwitch.isSwitching, fadeIn]);
|
|
|
|
return {
|
|
isOverlayFadingOut,
|
|
resetFadeOut,
|
|
isFadingIn,
|
|
onFadeInAnimationEnd,
|
|
resetFadeIn,
|
|
};
|
|
}
|