39948-vm/frontend/src/hooks/useBackgroundTransition.ts
2026-04-14 15:21:10 +04:00

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,
};
}