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'
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
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'
}`}
style={{ opacity: videoOpacity }}

View File

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

View File

@ -43,18 +43,13 @@ export const CANVAS_CONFIG = {
// Page transition effects
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.
* 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,
},

View File

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

View File

@ -2,16 +2,18 @@
* useBackgroundTransition Hook
*
* Manages background transition effects when switching between pages.
* Handles the fade-out animation of the transition video overlay,
* fade-in animation for non-transition navigation, and
* 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. Full mode (RuntimePresentation): Fade-out animation + fade-in + direct navigation clearing
* 2. Simple mode (constructor): Direct navigation clearing + optional fade-in
* 1. fadeIn mode: Crossfade animation for direct (non-video) page navigation
* 2. fadeOut mode: Legacy support - kept for backwards compatibility but not recommended
*/
import {
@ -21,13 +23,11 @@ import {
useCallback,
useRef,
} from 'react';
import { CANVAS_CONFIG } from '../config/canvas.config';
import { isSafari, scheduleAfterPaintSafari } from '../lib/browserUtils';
/**
* Fade-out duration from config (for transition video overlay)
*/
const FADE_OUT_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeOutDurationMs;
import {
isSafari,
scheduleAfterPaintSafari,
getCrossfadeDuration,
} from '../lib/browserUtils';
/**
* Fade-out configuration (optional - for RuntimePresentation)
@ -194,6 +194,7 @@ export function useBackgroundTransition({
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) {
@ -209,7 +210,7 @@ export function useBackgroundTransition({
// Reset fade-out state
setIsOverlayFadingOut(false);
}, FADE_OUT_DURATION_MS);
}, getCrossfadeDuration());
return () => clearTimeout(fadeTimer);
}, [fadeOut, isOverlayFadingOut, pageSwitch]);

View File

@ -166,6 +166,30 @@ export const scheduleAfterPaint = (callback: () => void): void => {
}, 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.
* 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;
initialIndex: number;
} | 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 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
// Uses usePageSwitch hook to resolve blob URLs from preload cache
// Also updates storage path state for editing/saving purposes
@ -430,17 +464,15 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
clearSelection();
setSelectedMenuItem('none');
setErrorMessage('');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
video?.removeAttribute('src');
video?.load();
closeTransitionPreview();
});
});
setIsBackgroundReady(false);
// Signal that transition video completed - wait for background to load
// Overlay will be removed instantly when isBackgroundReady becomes true
setPendingTransitionComplete(true);
} else {
video?.removeAttribute('src');
video?.load();
closeTransitionPreview();
setPendingTransitionComplete(false);
}
},
timeouts: {
@ -1459,7 +1491,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
isSwitching={pageSwitch.isSwitching}
isNewBgReady={pageSwitch.isNewBgReady}
isFadingIn={isFadingIn}
onBackgroundReady={() => pageSwitch.markBackgroundReady()}
onBackgroundReady={() => {
pageSwitch.markBackgroundReady();
setIsBackgroundReady(true);
}}
videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop}
videoMuted={backgroundVideoMuted}