39948-vm/frontend/src/hooks/useSlideTransition.ts

160 lines
4.6 KiB
TypeScript

/**
* useSlideTransition Hook
*
* Manages slide transition animation for Gallery/Carousel elements.
* Implements fade-through-overlay: Slide 1 -> fade out -> overlay -> fade in -> Slide 2
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { CSSProperties } from 'react';
import type { ResolvedSlideTransition } from '../lib/resolveSlideTransition';
// Transition phases: idle -> fadingOut -> fadingIn -> idle
type TransitionPhase = 'idle' | 'fadingOut' | 'fadingIn';
interface SlideTransitionState {
currentIndex: number;
displayIndex: number; // What's actually shown (may differ during transition)
phase: TransitionPhase;
overlayOpacity: number; // 0 = hidden, 1 = fully visible
}
interface UseSlideTransitionReturn {
/** Current logical index */
currentIndex: number;
/** Index to display (follows currentIndex with delay during transition) */
displayIndex: number;
/** Current transition phase */
phase: TransitionPhase;
/** Whether any transition is active */
isTransitioning: boolean;
/** Overlay opacity (0-1) */
overlayOpacity: number;
/** Overlay color from settings */
overlayColor: string;
/** Navigate to specific slide index */
goToIndex: (index: number) => void;
/** Set initial index without transition */
setInitialIndex: (index: number) => void;
/** CSS transition style for slide image */
slideTransitionStyle: CSSProperties;
/** CSS transition style for overlay */
overlayTransitionStyle: CSSProperties;
/** Current slide opacity */
slideOpacity: number;
}
export function useSlideTransition(
settings: ResolvedSlideTransition,
): UseSlideTransitionReturn {
const [state, setState] = useState<SlideTransitionState>({
currentIndex: 0,
displayIndex: 0,
phase: 'idle',
overlayOpacity: 0,
});
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingIndexRef = useRef<number | null>(null);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
// Half duration for each phase (fade out + fade in)
const halfDuration = settings.durationMs / 2;
const goToIndex = useCallback(
(newIndex: number) => {
if (newIndex === state.currentIndex && state.phase === 'idle') return;
// Clear pending transition
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (settings.type === 'none') {
// Instant switch - no transition
setState({
currentIndex: newIndex,
displayIndex: newIndex,
phase: 'idle',
overlayOpacity: 0,
});
return;
}
// Store pending index
pendingIndexRef.current = newIndex;
// Phase 1: Fade out current slide (overlay fades in)
setState((prev) => ({
...prev,
currentIndex: newIndex,
phase: 'fadingOut',
overlayOpacity: 1,
}));
// Phase 2: At midpoint, switch display to new slide, start fade in
timeoutRef.current = setTimeout(() => {
setState((prev) => ({
...prev,
displayIndex: pendingIndexRef.current ?? prev.currentIndex,
phase: 'fadingIn',
overlayOpacity: 0,
}));
// Phase 3: Complete transition
timeoutRef.current = setTimeout(() => {
setState((prev) => ({
...prev,
phase: 'idle',
}));
pendingIndexRef.current = null;
}, halfDuration);
}, halfDuration);
},
[state.currentIndex, state.phase, settings.type, halfDuration],
);
const setInitialIndex = useCallback((index: number) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
pendingIndexRef.current = null;
setState({
currentIndex: index,
displayIndex: index,
phase: 'idle',
overlayOpacity: 0,
});
}, []);
// CSS transition styles
const slideTransitionStyle: CSSProperties =
settings.type === 'fade'
? { transition: `opacity ${halfDuration}ms ${settings.easing}` }
: {};
const overlayTransitionStyle: CSSProperties =
settings.type === 'fade'
? { transition: `opacity ${halfDuration}ms ${settings.easing}` }
: {};
// Slide opacity: visible in idle, fades based on phase
const slideOpacity = state.phase === 'fadingOut' ? 0 : 1;
return {
currentIndex: state.currentIndex,
displayIndex: state.displayIndex,
phase: state.phase,
isTransitioning: state.phase !== 'idle',
overlayOpacity: state.overlayOpacity,
overlayColor: settings.overlayColor,
goToIndex,
setInitialIndex,
slideTransitionStyle,
overlayTransitionStyle,
slideOpacity,
};
}