160 lines
4.6 KiB
TypeScript
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,
|
|
};
|
|
}
|