fixed fades issue

This commit is contained in:
Dmitri 2026-04-14 15:21:10 +04:00
parent 804e082ed7
commit 28b6f8fe71
8 changed files with 134 additions and 64 deletions

File diff suppressed because one or more lines are too long

View File

@ -53,9 +53,11 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
className='overflow-hidden' className='overflow-hidden'
style={letterboxStyles || { position: 'absolute', inset: 0 }} style={letterboxStyles || { position: 'absolute', inset: 0 }}
> >
{/* Video element - no opacity transition to ensure instant appearance
when ready. The video itself IS the transition effect. */}
<video <video
ref={videoRef} ref={videoRef}
className={`absolute inset-0 h-full w-full transition-opacity duration-300 ease-linear ${ className={`absolute inset-0 h-full w-full ${
videoFit === 'cover' ? 'object-cover' : 'object-contain' videoFit === 'cover' ? 'object-cover' : 'object-contain'
}`} }`}
style={{ opacity: videoOpacity }} style={{ opacity: videoOpacity }}

View File

@ -108,6 +108,7 @@ export default function RuntimePresentation({
} | null>(null); } | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [isBackgroundReady, setIsBackgroundReady] = useState(true); const [isBackgroundReady, setIsBackgroundReady] = useState(true);
// Track when transition video has completed but we're waiting for background to load
const [pendingTransitionComplete, setPendingTransitionComplete] = const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false); useState(false);
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{ const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
@ -212,7 +213,9 @@ export default function RuntimePresentation({
applyPageSelection(targetPageId, isBack ?? false); applyPageSelection(targetPageId, isBack ?? false);
}); });
setIsBackgroundReady(false); setIsBackgroundReady(false);
// Signal that transition is complete and waiting for Image onLoad // Video transition completed - last frame shows new page background
// Signal that we're waiting for background to load before removing overlay
// Overlay will be removed instantly (no fade) when isBackgroundReady becomes true
setPendingTransitionComplete(true); setPendingTransitionComplete(true);
} else { } else {
// No target page - clean up and remove overlay // No target page - clean up and remove overlay
@ -237,27 +240,17 @@ export default function RuntimePresentation({
}); });
// Use shared background transition hook for crossfade effects // Use shared background transition hook for crossfade effects
const { // NOTE: fadeOut config is NOT used for video transitions.
isOverlayFadingOut, // Video transitions end instantly (last frame = new page, then overlay removed).
resetFadeOut, // fadeIn is used for non-video navigation (crossfade 500ms).
isFadingIn, const { isFadingIn, onFadeInAnimationEnd, resetFadeIn } =
onFadeInAnimationEnd, useBackgroundTransition({
resetFadeIn, pageSwitch,
} = useBackgroundTransition({ // No fadeOut - video transitions don't use fade
pageSwitch, fadeIn: {
fadeOut: { hasActiveTransition: Boolean(transitionPreview),
pendingTransitionComplete, },
isBackgroundReady, });
transitionVideoRef,
onTransitionCleanup: useCallback(() => {
setTransitionPreview(null);
setPendingTransitionComplete(false);
}, []),
},
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
},
});
const toggleFullscreen = useCallback(async () => { const toggleFullscreen = useCallback(async () => {
try { try {
@ -336,6 +329,21 @@ export default function RuntimePresentation({
} }
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]); }, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
// Video transition overlay removal - instant (no fade) when background is ready
// This ensures UI elements have time to appear before we remove the transition overlay
useEffect(() => {
if (pendingTransitionComplete && isBackgroundReady) {
// Background is ready - instantly remove transition overlay (no fade)
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
setTransitionPreview(null);
setPendingTransitionComplete(false);
}
}, [pendingTransitionComplete, isBackgroundReady]);
// Safari Black Flash Prevention: // Safari Black Flash Prevention:
// Update lastKnownBgUrl when a background is successfully displayed. // Update lastKnownBgUrl when a background is successfully displayed.
// This creates a "snapshot" that persists through transitions. // This creates a "snapshot" that persists through transitions.
@ -367,10 +375,8 @@ export default function RuntimePresentation({
if (!targetPage) return; if (!targetPage) return;
if (transitionVideoUrl) { if (transitionVideoUrl) {
// Reset states from previous transition before starting new one // Reset states from previous transition/navigation
// This prevents the fade-out/fade-in effects from re-triggering
resetFadeIn(); resetFadeIn();
resetFadeOut();
setPendingTransitionComplete(false); setPendingTransitionComplete(false);
// Play transition using useTransitionPlayback hook // Play transition using useTransitionPlayback hook
setTransitionPreview({ setTransitionPreview({
@ -397,7 +403,7 @@ export default function RuntimePresentation({
}); });
} }
}, },
[pages, pageSwitch, resetFadeOut, resetFadeIn, applyPageSelection], [pages, pageSwitch, resetFadeIn, applyPageSelection],
); );
const handleElementClick = useCallback( const handleElementClick = useCallback(
@ -722,14 +728,15 @@ export default function RuntimePresentation({
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */} {/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* 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 */} {/* NO fade-out: video itself IS the transition (last frame = new page) */}
{/* Overlay removed instantly via setTransitionPreview(null) in onComplete */}
{transitionPreview && ( {transitionPreview && (
<TransitionPreviewOverlay <TransitionPreviewOverlay
videoRef={transitionVideoRef} videoRef={transitionVideoRef}
isActive={true} isActive={true}
isBuffering={transitionPhase === 'preparing' || isBuffering} isBuffering={transitionPhase === 'preparing' || isBuffering}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
opacity={isOverlayFadingOut ? 0 : 1} opacity={1}
/> />
)} )}

View File

@ -43,18 +43,13 @@ export const CANVAS_CONFIG = {
// Page transition effects // Page transition effects
pageTransition: { pageTransition: {
/**
* Fade-out duration for transition video overlay (ms).
* Applied after transition video finishes playing.
*/
fadeOutDurationMs: 300,
/**
* Crossfade animation duration for page backgrounds (ms).
* Used for smooth transitions between pages.
*/
crossfadeDurationMs: 300,
/** /**
* CSS easing function for fade animations. * CSS easing function for fade animations.
* Duration is controlled by CSS variable --crossfade-duration (single source of truth).
* Use getCrossfadeDuration() from browserUtils.ts to read the value in JS.
*
* NOTE: Video transitions do NOT use fade - video itself is the transition.
* Crossfade only applies to non-video page navigation.
*/ */
easing: 'ease-out' as const, easing: 'ease-out' as const,
}, },

View File

@ -12,6 +12,11 @@
@import "_theme.css"; @import "_theme.css";
@import '_rich-text.css'; @import '_rich-text.css';
/* Page transition timing - single source of truth */
:root {
--crossfade-duration: 700ms;
}
.introjs-tooltip { .introjs-tooltip {
@apply min-w-[400px] max-w-[480px] p-2 !important; @apply min-w-[400px] max-w-[480px] p-2 !important;
@ -84,13 +89,14 @@
} }
/* Crossfade animation classes - GPU accelerated for Safari */ /* Crossfade animation classes - GPU accelerated for Safari */
/* Duration controlled by --crossfade-duration CSS variable (single source of truth) */
.animate-crossfade-in { .animate-crossfade-in {
/* Explicit initial state prevents Safari flash during animation setup */ /* Explicit initial state prevents Safari flash during animation setup */
opacity: 0; opacity: 0;
-webkit-transform: translate3d(0, 0, 0); -webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
-webkit-animation: page-crossfade-in 300ms ease-out forwards; -webkit-animation: page-crossfade-in var(--crossfade-duration, 700ms) ease-out forwards;
animation: page-crossfade-in 300ms ease-out forwards; animation: page-crossfade-in var(--crossfade-duration, 700ms) ease-out forwards;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
backface-visibility: hidden; backface-visibility: hidden;
/* Only animate opacity - transform is for GPU layer promotion */ /* Only animate opacity - transform is for GPU layer promotion */
@ -102,8 +108,8 @@
opacity: 1; opacity: 1;
-webkit-transform: translate3d(0, 0, 0); -webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
-webkit-animation: page-crossfade-out 300ms ease-out forwards; -webkit-animation: page-crossfade-out var(--crossfade-duration, 700ms) ease-out forwards;
animation: page-crossfade-out 300ms ease-out forwards; animation: page-crossfade-out var(--crossfade-duration, 700ms) ease-out forwards;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
backface-visibility: hidden; backface-visibility: hidden;
/* Only animate opacity - transform is for GPU layer promotion */ /* Only animate opacity - transform is for GPU layer promotion */

View File

@ -2,16 +2,18 @@
* useBackgroundTransition Hook * useBackgroundTransition Hook
* *
* Manages background transition effects when switching between pages. * Manages background transition effects when switching between pages.
* Handles the fade-out animation of the transition video overlay, * Handles crossfade animation for non-video navigation and
* fade-in animation for non-transition navigation, and
* coordinates with the page switch hook to clear previous backgrounds. * coordinates with the page switch hook to clear previous backgrounds.
* *
* This hook consolidates the background transition logic used by both * This hook consolidates the background transition logic used by both
* RuntimePresentation and constructor.tsx. * 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: * Two modes:
* 1. Full mode (RuntimePresentation): Fade-out animation + fade-in + direct navigation clearing * 1. fadeIn mode: Crossfade animation for direct (non-video) page navigation
* 2. Simple mode (constructor): Direct navigation clearing + optional fade-in * 2. fadeOut mode: Legacy support - kept for backwards compatibility but not recommended
*/ */
import { import {
@ -21,13 +23,11 @@ import {
useCallback, useCallback,
useRef, useRef,
} from 'react'; } from 'react';
import { CANVAS_CONFIG } from '../config/canvas.config'; import {
import { isSafari, scheduleAfterPaintSafari } from '../lib/browserUtils'; isSafari,
scheduleAfterPaintSafari,
/** getCrossfadeDuration,
* Fade-out duration from config (for transition video overlay) } from '../lib/browserUtils';
*/
const FADE_OUT_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeOutDurationMs;
/** /**
* Fade-out configuration (optional - for RuntimePresentation) * Fade-out configuration (optional - for RuntimePresentation)
@ -194,6 +194,7 @@ export function useBackgroundTransition({
const { transitionVideoRef, onTransitionCleanup } = fadeOut; const { transitionVideoRef, onTransitionCleanup } = fadeOut;
// After fade completes, remove the overlay // After fade completes, remove the overlay
// Duration is read from CSS variable for consistency
const fadeTimer = setTimeout(() => { const fadeTimer = setTimeout(() => {
const video = transitionVideoRef.current; const video = transitionVideoRef.current;
if (video) { if (video) {
@ -209,7 +210,7 @@ export function useBackgroundTransition({
// Reset fade-out state // Reset fade-out state
setIsOverlayFadingOut(false); setIsOverlayFadingOut(false);
}, FADE_OUT_DURATION_MS); }, getCrossfadeDuration());
return () => clearTimeout(fadeTimer); return () => clearTimeout(fadeTimer);
}, [fadeOut, isOverlayFadingOut, pageSwitch]); }, [fadeOut, isOverlayFadingOut, pageSwitch]);

View File

@ -166,6 +166,30 @@ export const scheduleAfterPaint = (callback: () => void): void => {
}, 0); }, 0);
}; };
/**
* Get crossfade duration from CSS custom property.
* Single source of truth: CSS variable --crossfade-duration in main.css.
*
* @returns Duration in milliseconds (default: 500ms)
*/
export const getCrossfadeDuration = (): number => {
if (typeof window === 'undefined') return 700; // SSR fallback
const root = document.documentElement;
const value = getComputedStyle(root)
.getPropertyValue('--crossfade-duration')
.trim();
// Parse "700ms" or "0.7s" to milliseconds
if (value.endsWith('ms')) {
return parseInt(value, 10) || 700;
}
if (value.endsWith('s')) {
return (parseFloat(value) || 0.7) * 1000;
}
return 700; // fallback
};
/** /**
* Schedule a callback with explicit frame delay. * Schedule a callback with explicit frame delay.
* Useful when you need to ensure a specific number of frames have passed. * Useful when you need to ensure a specific number of frames have passed.

View File

@ -219,6 +219,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
element: CanvasElement; element: CanvasElement;
initialIndex: number; initialIndex: number;
} | null>(null); } | null>(null);
// Track background ready state for smooth video transition completion
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
const isConstructorEditMode = constructorInteractionMode === 'edit'; const isConstructorEditMode = constructorInteractionMode === 'edit';
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => { const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
@ -368,6 +372,36 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}, },
}); });
// Video transition overlay removal - instant (no fade) when background is ready
// This ensures UI elements have time to appear before we remove the transition overlay
useEffect(() => {
if (pendingTransitionComplete && isBackgroundReady) {
// Background is ready - instantly remove transition overlay (no fade)
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
closeTransitionPreview();
setPendingTransitionComplete(false);
}
}, [pendingTransitionComplete, isBackgroundReady, closeTransitionPreview]);
// Handle background ready state for pages without images or with videos
useEffect(() => {
// If no background image, or if there's a video (video takes over), mark as ready
if (!activePage?.background_image_url || activePage?.background_video_url) {
setIsBackgroundReady(true);
}
}, [activePage?.background_image_url, activePage?.background_video_url]);
// Reset pending state when starting a new transition
useEffect(() => {
if (transitionPreview) {
setPendingTransitionComplete(false);
}
}, [transitionPreview]);
// Helper to switch pages without flash // Helper to switch pages without flash
// Uses usePageSwitch hook to resolve blob URLs from preload cache // Uses usePageSwitch hook to resolve blob URLs from preload cache
// Also updates storage path state for editing/saving purposes // Also updates storage path state for editing/saving purposes
@ -430,17 +464,15 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
clearSelection(); clearSelection();
setSelectedMenuItem('none'); setSelectedMenuItem('none');
setErrorMessage(''); setErrorMessage('');
requestAnimationFrame(() => { setIsBackgroundReady(false);
requestAnimationFrame(() => { // Signal that transition video completed - wait for background to load
video?.removeAttribute('src'); // Overlay will be removed instantly when isBackgroundReady becomes true
video?.load(); setPendingTransitionComplete(true);
closeTransitionPreview();
});
});
} else { } else {
video?.removeAttribute('src'); video?.removeAttribute('src');
video?.load(); video?.load();
closeTransitionPreview(); closeTransitionPreview();
setPendingTransitionComplete(false);
} }
}, },
timeouts: { timeouts: {
@ -1459,7 +1491,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
isSwitching={pageSwitch.isSwitching} isSwitching={pageSwitch.isSwitching}
isNewBgReady={pageSwitch.isNewBgReady} isNewBgReady={pageSwitch.isNewBgReady}
isFadingIn={isFadingIn} isFadingIn={isFadingIn}
onBackgroundReady={() => pageSwitch.markBackgroundReady()} onBackgroundReady={() => {
pageSwitch.markBackgroundReady();
setIsBackgroundReady(true);
}}
videoAutoplay={backgroundVideoAutoplay} videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop} videoLoop={backgroundVideoLoop}
videoMuted={backgroundVideoMuted} videoMuted={backgroundVideoMuted}