fixed blur issue
This commit is contained in:
parent
61e707f2ba
commit
ce68bad8ba
File diff suppressed because one or more lines are too long
116
frontend/src/components/BackdropPortal.tsx
Normal file
116
frontend/src/components/BackdropPortal.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* BackdropPortal Component
|
||||
*
|
||||
* Renders backdrop blur effects outside the CSS transform hierarchy.
|
||||
* CSS backdrop-filter doesn't work when a parent element has transform applied
|
||||
* (like translate(-50%, -50%) used for centering elements).
|
||||
*
|
||||
* This component provides a context for elements to register their backdrop
|
||||
* effects, which are then rendered in a separate layer without transform ancestors.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
interface BackdropItem {
|
||||
id: string;
|
||||
style: React.CSSProperties;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
interface BackdropContextValue {
|
||||
registerBackdrop: (
|
||||
id: string,
|
||||
style: React.CSSProperties,
|
||||
zIndex: number,
|
||||
) => void;
|
||||
unregisterBackdrop: (id: string) => void;
|
||||
updateBackdrop: (id: string, style: React.CSSProperties) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const BackdropContext = createContext<BackdropContextValue | null>(null);
|
||||
|
||||
interface BackdropPortalProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BackdropPortalProvider: React.FC<BackdropPortalProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [backdrops, setBackdrops] = useState<Map<string, BackdropItem>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const registerBackdrop = useCallback(
|
||||
(id: string, style: React.CSSProperties, zIndex: number) => {
|
||||
setBackdrops((prev) => new Map(prev).set(id, { id, style, zIndex }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unregisterBackdrop = useCallback((id: string) => {
|
||||
setBackdrops((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateBackdrop = useCallback(
|
||||
(id: string, style: React.CSSProperties) => {
|
||||
setBackdrops((prev) => {
|
||||
const existing = prev.get(id);
|
||||
if (!existing) return prev;
|
||||
return new Map(prev).set(id, { ...existing, style });
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
registerBackdrop,
|
||||
unregisterBackdrop,
|
||||
updateBackdrop,
|
||||
containerRef,
|
||||
}),
|
||||
[registerBackdrop, unregisterBackdrop, updateBackdrop],
|
||||
);
|
||||
|
||||
// Sort backdrops by zIndex for proper layering
|
||||
const sortedBackdrops = useMemo(
|
||||
() => Array.from(backdrops.values()).sort((a, b) => a.zIndex - b.zIndex),
|
||||
[backdrops],
|
||||
);
|
||||
|
||||
return (
|
||||
<BackdropContext.Provider value={contextValue}>
|
||||
{/* Backdrop container - z-index: 5 keeps it above background (z-1) but below elements (z-10).
|
||||
The backdrop-filter will blur only the background behind it. */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='absolute inset-0 pointer-events-none overflow-hidden z-5'
|
||||
style={{ zIndex: 5 }}
|
||||
>
|
||||
{sortedBackdrops.map(({ id, style }) => (
|
||||
<div key={id} className='absolute' style={style} />
|
||||
))}
|
||||
</div>
|
||||
{children}
|
||||
</BackdropContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBackdropPortal = (): BackdropContextValue | null => {
|
||||
return useContext(BackdropContext);
|
||||
};
|
||||
|
||||
export default BackdropPortalProvider;
|
||||
@ -37,9 +37,9 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Background image */}
|
||||
{/* Background image - z-1 keeps it below backdrop blur layer (z-5) */}
|
||||
{backgroundImageUrl && (
|
||||
<div className='absolute inset-0 h-full w-full pointer-events-none select-none'>
|
||||
<div className='absolute inset-0 z-1 h-full w-full pointer-events-none select-none'>
|
||||
{backgroundImageUrl.startsWith('blob:') ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
@ -80,11 +80,11 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Background video */}
|
||||
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) */}
|
||||
{backgroundVideoUrl && (
|
||||
<video
|
||||
key={`bg_video_${backgroundVideoUrl}`}
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
className='absolute inset-0 z-1 w-full h-full object-cover'
|
||||
src={backgroundVideoUrl}
|
||||
autoPlay
|
||||
loop
|
||||
|
||||
@ -21,6 +21,7 @@ import CardBox from './CardBox';
|
||||
import { OfflineToggle } from './Offline/OfflineToggle';
|
||||
import RuntimeElement from './RuntimeElement';
|
||||
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
||||
import { BackdropPortalProvider } from './BackdropPortal';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
import { usePageDataLoader } from '../hooks/usePageDataLoader';
|
||||
@ -439,166 +440,175 @@ export default function RuntimePresentation({
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Background image element - CSS backgroundImage provides instant display.
|
||||
<BackdropPortalProvider>
|
||||
{/* Background image element - z-1 keeps it below backdrop blur (z-5).
|
||||
CSS backgroundImage provides instant display.
|
||||
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */}
|
||||
{backgroundImageUrl && !backgroundVideoUrl && (
|
||||
<div className='absolute inset-0 pointer-events-none'>
|
||||
{backgroundImageUrl.startsWith('blob:') ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={backgroundImageUrl}
|
||||
src={backgroundImageUrl}
|
||||
alt=''
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
onLoad={() => {
|
||||
setIsBackgroundReady(true);
|
||||
pageSwitch.markBackgroundReady();
|
||||
}}
|
||||
onError={() => {
|
||||
setIsBackgroundReady(true);
|
||||
pageSwitch.markBackgroundReady();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
key={backgroundImageUrl}
|
||||
src={backgroundImageUrl}
|
||||
alt=''
|
||||
fill
|
||||
sizes='100vw'
|
||||
className='object-cover'
|
||||
priority
|
||||
unoptimized
|
||||
onLoad={() => {
|
||||
setIsBackgroundReady(true);
|
||||
pageSwitch.markBackgroundReady();
|
||||
}}
|
||||
onError={() => {
|
||||
setIsBackgroundReady(true);
|
||||
pageSwitch.markBackgroundReady();
|
||||
{backgroundImageUrl && !backgroundVideoUrl && (
|
||||
<div className='absolute inset-0 z-1 pointer-events-none'>
|
||||
{backgroundImageUrl.startsWith('blob:') ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={backgroundImageUrl}
|
||||
src={backgroundImageUrl}
|
||||
alt=''
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
onLoad={() => {
|
||||
setIsBackgroundReady(true);
|
||||
pageSwitch.markBackgroundReady();
|
||||
}}
|
||||
onError={() => {
|
||||
setIsBackgroundReady(true);
|
||||
pageSwitch.markBackgroundReady();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
key={backgroundImageUrl}
|
||||
src={backgroundImageUrl}
|
||||
alt=''
|
||||
fill
|
||||
sizes='100vw'
|
||||
className='object-cover'
|
||||
priority
|
||||
unoptimized
|
||||
onLoad={() => {
|
||||
setIsBackgroundReady(true);
|
||||
pageSwitch.markBackgroundReady();
|
||||
}}
|
||||
onError={() => {
|
||||
setIsBackgroundReady(true);
|
||||
pageSwitch.markBackgroundReady();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Previous background overlay - shows during direct navigation until new bg is ready */}
|
||||
{pageSwitch.previousBgImageUrl &&
|
||||
pageSwitch.isSwitching &&
|
||||
!pageSwitch.isNewBgReady && (
|
||||
<div
|
||||
className='absolute inset-0 pointer-events-none z-10'
|
||||
style={{
|
||||
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Previous background overlay - shows during direct navigation until new bg is ready */}
|
||||
{pageSwitch.previousBgImageUrl &&
|
||||
pageSwitch.isSwitching &&
|
||||
!pageSwitch.isNewBgReady && (
|
||||
<div
|
||||
className='absolute inset-0 pointer-events-none z-10'
|
||||
style={{
|
||||
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
{/* Background video - z-1 keeps it below backdrop blur (z-5) */}
|
||||
{backgroundVideoUrl && (
|
||||
<video
|
||||
key={backgroundVideoUrl}
|
||||
className='absolute inset-0 z-1 w-full h-full object-cover'
|
||||
src={backgroundVideoUrl}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Background video */}
|
||||
{backgroundVideoUrl && (
|
||||
<video
|
||||
key={backgroundVideoUrl}
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
src={backgroundVideoUrl}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
{/* Page elements - z-10 ensures they appear above backdrop layer */}
|
||||
<div className='absolute inset-0 z-10'>
|
||||
{pageElements.map((element: any) => (
|
||||
<RuntimeElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
onClick={() => handleElementClick(element)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
onGalleryCardClick={(cardIndex) =>
|
||||
handleGalleryCardClick(element, cardIndex)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Page elements */}
|
||||
<div className='absolute inset-0'>
|
||||
{pageElements.map((element: any) => (
|
||||
<RuntimeElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
onClick={() => handleElementClick(element)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
onGalleryCardClick={(cardIndex) =>
|
||||
handleGalleryCardClick(element, cardIndex)
|
||||
}
|
||||
{/* Controls: Offline toggle and Fullscreen button */}
|
||||
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
|
||||
<OfflineToggle
|
||||
projectId={project?.id || null}
|
||||
projectSlug={projectSlug}
|
||||
projectName={project?.name}
|
||||
showLabel={false}
|
||||
size='small'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Controls: Offline toggle and Fullscreen button */}
|
||||
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
|
||||
<OfflineToggle
|
||||
projectId={project?.id || null}
|
||||
projectSlug={projectSlug}
|
||||
projectName={project?.name}
|
||||
showLabel={false}
|
||||
size='small'
|
||||
/>
|
||||
<BaseButton
|
||||
icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen}
|
||||
color='info'
|
||||
small
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
{transitionPreview && (
|
||||
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
|
||||
<video
|
||||
ref={transitionVideoRef}
|
||||
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
|
||||
style={{
|
||||
opacity:
|
||||
transitionPhase === 'preparing' ||
|
||||
isBuffering ||
|
||||
isOverlayFadingOut
|
||||
? 0
|
||||
: 1,
|
||||
}}
|
||||
muted
|
||||
playsInline
|
||||
preload='auto'
|
||||
disablePictureInPicture
|
||||
<BaseButton
|
||||
icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen}
|
||||
color='info'
|
||||
small
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery Carousel Overlay */}
|
||||
{activeGalleryCarousel && (
|
||||
<GalleryCarouselOverlay
|
||||
cards={activeGalleryCarousel.element.galleryCards || []}
|
||||
initialIndex={activeGalleryCarousel.initialIndex}
|
||||
onClose={() => setActiveGalleryCarousel(null)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
prevIconUrl={
|
||||
activeGalleryCarousel.element.galleryCarouselPrevIconUrl
|
||||
}
|
||||
nextIconUrl={
|
||||
activeGalleryCarousel.element.galleryCarouselNextIconUrl
|
||||
}
|
||||
backIconUrl={
|
||||
activeGalleryCarousel.element.galleryCarouselBackIconUrl
|
||||
}
|
||||
backLabel={
|
||||
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
|
||||
}
|
||||
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
|
||||
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
|
||||
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
|
||||
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
|
||||
backX={activeGalleryCarousel.element.galleryCarouselBackX}
|
||||
backY={activeGalleryCarousel.element.galleryCarouselBackY}
|
||||
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
|
||||
prevHeight={activeGalleryCarousel.element.galleryCarouselPrevHeight}
|
||||
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
|
||||
nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight}
|
||||
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
|
||||
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
|
||||
isEditMode={false}
|
||||
/>
|
||||
)}
|
||||
{/* 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 */}
|
||||
{transitionPreview && (
|
||||
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
|
||||
<video
|
||||
ref={transitionVideoRef}
|
||||
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
|
||||
style={{
|
||||
opacity:
|
||||
transitionPhase === 'preparing' ||
|
||||
isBuffering ||
|
||||
isOverlayFadingOut
|
||||
? 0
|
||||
: 1,
|
||||
}}
|
||||
muted
|
||||
playsInline
|
||||
preload='auto'
|
||||
disablePictureInPicture
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery Carousel Overlay */}
|
||||
{activeGalleryCarousel && (
|
||||
<GalleryCarouselOverlay
|
||||
cards={activeGalleryCarousel.element.galleryCards || []}
|
||||
initialIndex={activeGalleryCarousel.initialIndex}
|
||||
onClose={() => setActiveGalleryCarousel(null)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
prevIconUrl={
|
||||
activeGalleryCarousel.element.galleryCarouselPrevIconUrl
|
||||
}
|
||||
nextIconUrl={
|
||||
activeGalleryCarousel.element.galleryCarouselNextIconUrl
|
||||
}
|
||||
backIconUrl={
|
||||
activeGalleryCarousel.element.galleryCarouselBackIconUrl
|
||||
}
|
||||
backLabel={
|
||||
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
|
||||
}
|
||||
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
|
||||
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
|
||||
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
|
||||
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
|
||||
backX={activeGalleryCarousel.element.galleryCarouselBackX}
|
||||
backY={activeGalleryCarousel.element.galleryCarouselBackY}
|
||||
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
|
||||
prevHeight={
|
||||
activeGalleryCarousel.element.galleryCarouselPrevHeight
|
||||
}
|
||||
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
|
||||
nextHeight={
|
||||
activeGalleryCarousel.element.galleryCarouselNextHeight
|
||||
}
|
||||
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
|
||||
backHeight={
|
||||
activeGalleryCarousel.element.galleryCarouselBackHeight
|
||||
}
|
||||
isEditMode={false}
|
||||
/>
|
||||
)}
|
||||
</BackdropPortalProvider>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
buildGalleryCardGridStyle,
|
||||
GALLERY_SECTION_DEFAULTS,
|
||||
} from '../../../lib/gallerySectionStyles';
|
||||
import { useBackdropEffect } from '../../../hooks/useBackdropEffect';
|
||||
|
||||
interface GalleryElementProps {
|
||||
element: CanvasElement;
|
||||
@ -69,17 +70,42 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
|
||||
|
||||
// Build wrapper style from general element styles with gallery defaults
|
||||
const wrapperDefaults = GALLERY_SECTION_DEFAULTS.wrapper;
|
||||
|
||||
// Use portal-based backdrop for blur effect (works around CSS transform stacking context issue)
|
||||
const backdropBorderRadius =
|
||||
(style.borderRadius as string) ||
|
||||
(wrapperDefaults.borderRadius as string) ||
|
||||
'0.75rem';
|
||||
const backdropBackgroundColor =
|
||||
(style.backgroundColor as string) ||
|
||||
(wrapperDefaults.backgroundColor as string) ||
|
||||
'rgba(0, 0, 0, 0.6)';
|
||||
const backdropFilterValue =
|
||||
(wrapperDefaults.backdropFilter as string) || 'blur(4px)';
|
||||
const { ref: backdropRef, isPortalAvailable } = useBackdropEffect({
|
||||
enabled: true,
|
||||
backdropFilter: backdropFilterValue,
|
||||
backgroundColor: backdropBackgroundColor,
|
||||
borderRadius: backdropBorderRadius,
|
||||
});
|
||||
|
||||
// Wrapper style - backdrop effects handled by portal when available,
|
||||
// otherwise fall back to inline styles (may not work with CSS transforms)
|
||||
const wrapperStyle: CSSProperties = useMemo(
|
||||
() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: style.backgroundColor || wrapperDefaults.backgroundColor,
|
||||
// When portal is available, it handles background + blur, so use transparent here
|
||||
// When portal is not available, fall back to inline styles
|
||||
backgroundColor: isPortalAvailable
|
||||
? 'transparent'
|
||||
: backdropBackgroundColor,
|
||||
backdropFilter: isPortalAvailable ? undefined : backdropFilterValue,
|
||||
WebkitBackdropFilter: isPortalAvailable ? undefined : backdropFilterValue,
|
||||
padding: style.padding || wrapperDefaults.padding,
|
||||
borderRadius: style.borderRadius || wrapperDefaults.borderRadius,
|
||||
border: style.border,
|
||||
gap: style.gap || wrapperDefaults.gap,
|
||||
backdropFilter: wrapperDefaults.backdropFilter,
|
||||
WebkitBackdropFilter: wrapperDefaults.backdropFilter,
|
||||
// Visual properties that should apply to the wrapper, not outer positioning div
|
||||
boxShadow: style.boxShadow,
|
||||
opacity: style.opacity,
|
||||
@ -90,7 +116,9 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
|
||||
lineHeight: style.lineHeight,
|
||||
}),
|
||||
[
|
||||
style.backgroundColor,
|
||||
isPortalAvailable,
|
||||
backdropBackgroundColor,
|
||||
backdropFilterValue,
|
||||
style.padding,
|
||||
style.borderRadius,
|
||||
style.border,
|
||||
@ -123,7 +151,7 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
|
||||
|
||||
return (
|
||||
<div className={className} style={outerStyle}>
|
||||
<div className='min-w-[200px]' style={wrapperStyle}>
|
||||
<div ref={backdropRef} className='min-w-[200px]' style={wrapperStyle}>
|
||||
{/* Header: image takes priority, otherwise render text */}
|
||||
{/* Header styles (border, borderRadius, dimensions) apply to both image and text modes */}
|
||||
{headerImageUrl ? (
|
||||
|
||||
190
frontend/src/hooks/useBackdropEffect.ts
Normal file
190
frontend/src/hooks/useBackdropEffect.ts
Normal file
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* useBackdropEffect Hook
|
||||
*
|
||||
* Enables backdrop-filter effects on elements inside CSS transform contexts.
|
||||
* Registers a backdrop layer in the BackdropPortal that renders outside the
|
||||
* transform hierarchy, allowing backdrop-filter to work properly.
|
||||
*
|
||||
* When BackdropPortalProvider is not available (e.g., standalone usage),
|
||||
* the hook gracefully degrades and returns `isPortalAvailable: false` so
|
||||
* the component can apply inline backdrop styles as fallback.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { ref, isPortalAvailable } = useBackdropEffect({
|
||||
* enabled: true,
|
||||
* backdropFilter: 'blur(4px)',
|
||||
* backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
* borderRadius: '0.75rem',
|
||||
* });
|
||||
*
|
||||
* return (
|
||||
* <div
|
||||
* ref={ref}
|
||||
* style={isPortalAvailable ? { backgroundColor: 'transparent' } : {
|
||||
* backdropFilter: 'blur(4px)',
|
||||
* backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
* }}
|
||||
* >
|
||||
* Content
|
||||
* </div>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useId, useCallback } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useBackdropPortal } from '../components/BackdropPortal';
|
||||
|
||||
interface UseBackdropEffectOptions {
|
||||
/** Whether the backdrop effect is enabled */
|
||||
enabled: boolean;
|
||||
/** CSS backdrop-filter value (e.g., 'blur(4px)') */
|
||||
backdropFilter: string;
|
||||
/** Background color for the backdrop layer */
|
||||
backgroundColor: string;
|
||||
/** Border radius to match the element's shape */
|
||||
borderRadius?: string;
|
||||
/** Z-index for layering multiple backdrops */
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
interface UseBackdropEffectResult {
|
||||
/** Ref to attach to the element that should have the backdrop effect */
|
||||
ref: React.RefObject<HTMLDivElement | null>;
|
||||
/** Whether the portal is available (BackdropPortalProvider is present) */
|
||||
isPortalAvailable: boolean;
|
||||
}
|
||||
|
||||
export function useBackdropEffect(
|
||||
options: UseBackdropEffectOptions,
|
||||
): UseBackdropEffectResult {
|
||||
const {
|
||||
enabled,
|
||||
backdropFilter,
|
||||
backgroundColor,
|
||||
borderRadius,
|
||||
zIndex = 0,
|
||||
} = options;
|
||||
const id = useId();
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const backdropPortal = useBackdropPortal();
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
|
||||
const isPortalAvailable = backdropPortal !== null;
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!backdropPortal) return;
|
||||
|
||||
const el = elementRef.current;
|
||||
const container = backdropPortal.containerRef.current;
|
||||
if (!el || !container) return;
|
||||
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculate position relative to backdrop container
|
||||
const style: CSSProperties = {
|
||||
left: elRect.left - containerRect.left,
|
||||
top: elRect.top - containerRect.top,
|
||||
width: elRect.width,
|
||||
height: elRect.height,
|
||||
backdropFilter,
|
||||
WebkitBackdropFilter: backdropFilter,
|
||||
backgroundColor,
|
||||
borderRadius,
|
||||
};
|
||||
|
||||
backdropPortal.updateBackdrop(id, style);
|
||||
}, [backdropPortal, id, backdropFilter, backgroundColor, borderRadius]);
|
||||
|
||||
// Throttled position update using requestAnimationFrame
|
||||
const schedulePositionUpdate = useCallback(() => {
|
||||
if (rafIdRef.current !== null) return;
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
updatePosition();
|
||||
});
|
||||
}, [updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !backdropPortal) return;
|
||||
|
||||
const el = elementRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Register backdrop with initial empty style
|
||||
backdropPortal.registerBackdrop(id, {}, zIndex);
|
||||
|
||||
// Initial position update
|
||||
updatePosition();
|
||||
|
||||
// Update position on resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
schedulePositionUpdate();
|
||||
});
|
||||
resizeObserver.observe(el);
|
||||
|
||||
// Update position on window resize (viewport changes)
|
||||
const handleResize = () => schedulePositionUpdate();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Update position on scroll (in case of scrollable containers)
|
||||
const handleScroll = () => schedulePositionUpdate();
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
|
||||
// Use IntersectionObserver to detect when element position might change
|
||||
// This is more efficient than MutationObserver on document.body
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
() => {
|
||||
schedulePositionUpdate();
|
||||
},
|
||||
{ threshold: [0, 0.5, 1] },
|
||||
);
|
||||
intersectionObserver.observe(el);
|
||||
|
||||
return () => {
|
||||
backdropPortal.unregisterBackdrop(id);
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
backdropPortal,
|
||||
id,
|
||||
zIndex,
|
||||
updatePosition,
|
||||
schedulePositionUpdate,
|
||||
]);
|
||||
|
||||
// Update position when style properties change
|
||||
useEffect(() => {
|
||||
if (enabled && backdropPortal) {
|
||||
updatePosition();
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
backdropPortal,
|
||||
backdropFilter,
|
||||
backgroundColor,
|
||||
borderRadius,
|
||||
updatePosition,
|
||||
]);
|
||||
|
||||
// Update position after every render to catch position changes during drag operations
|
||||
// useLayoutEffect runs synchronously after DOM mutations but before paint
|
||||
useLayoutEffect(() => {
|
||||
if (enabled && backdropPortal && elementRef.current) {
|
||||
updatePosition();
|
||||
}
|
||||
});
|
||||
|
||||
return { ref: elementRef, isPortalAvailable };
|
||||
}
|
||||
|
||||
export default useBackdropEffect;
|
||||
@ -18,6 +18,7 @@ import TransitionPreviewOverlay from '../components/Constructor/TransitionPrevie
|
||||
import CanvasElementComponent from '../components/Constructor/CanvasElement';
|
||||
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
|
||||
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
||||
import { BackdropPortalProvider } from '../components/BackdropPortal';
|
||||
import { getPageTitle } from '../config';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
@ -1305,61 +1306,68 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
className='absolute inset-0 bg-black overflow-clip'
|
||||
style={canvasBackgroundStyle}
|
||||
>
|
||||
<CanvasBackground
|
||||
backgroundImageUrl={backgroundImageSrc}
|
||||
backgroundVideoUrl={backgroundVideoSrc}
|
||||
backgroundAudioUrl={backgroundAudioSrc}
|
||||
previousBgImageUrl={pageSwitch.previousBgImageUrl}
|
||||
isSwitching={pageSwitch.isSwitching}
|
||||
isNewBgReady={pageSwitch.isNewBgReady}
|
||||
onBackgroundReady={() => pageSwitch.markBackgroundReady()}
|
||||
/>
|
||||
<BackdropPortalProvider>
|
||||
<CanvasBackground
|
||||
backgroundImageUrl={backgroundImageSrc}
|
||||
backgroundVideoUrl={backgroundVideoSrc}
|
||||
backgroundAudioUrl={backgroundAudioSrc}
|
||||
previousBgImageUrl={pageSwitch.previousBgImageUrl}
|
||||
isSwitching={pageSwitch.isSwitching}
|
||||
isNewBgReady={pageSwitch.isNewBgReady}
|
||||
onBackgroundReady={() => pageSwitch.markBackgroundReady()}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<p className='text-sm text-gray-500'>Loading constructor...</p>
|
||||
{/* Elements container - z-10 ensures they appear above backdrop layer */}
|
||||
<div className='absolute inset-0 z-10'>
|
||||
{isLoading ? (
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<p className='text-sm text-gray-500'>Loading constructor...</p>
|
||||
</div>
|
||||
) : pages.length === 0 ? (
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<BaseButton
|
||||
color='info'
|
||||
label={isCreatingPage ? 'Creating...' : 'Create First Page'}
|
||||
icon={mdiPlus}
|
||||
onClick={createPage}
|
||||
disabled={isCreatingPage}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
elements.map((element) => {
|
||||
const shouldRender =
|
||||
selectedElementId === element.id ||
|
||||
(isElementVisibleOnCanvas(element) &&
|
||||
isElementReadyForCanvasRender(element));
|
||||
if (!shouldRender) return null;
|
||||
|
||||
const isNavDisabled =
|
||||
isNavigationElementType(element.type) &&
|
||||
(element.navDisabled ||
|
||||
Boolean(transitionPreview) ||
|
||||
isReverseBuffering);
|
||||
|
||||
return (
|
||||
<CanvasElementComponent
|
||||
key={element.id}
|
||||
element={element}
|
||||
isSelected={selectedElementId === element.id}
|
||||
isEditMode={isConstructorEditMode}
|
||||
isDisabled={isNavDisabled}
|
||||
onClick={() => onCanvasElementClick(element)}
|
||||
onMouseDown={(event) =>
|
||||
onElementMouseDown(event, element.id)
|
||||
}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
onGalleryCardClick={(cardIndex) =>
|
||||
handleGalleryCardClick(element, cardIndex)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : pages.length === 0 ? (
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<BaseButton
|
||||
color='info'
|
||||
label={isCreatingPage ? 'Creating...' : 'Create First Page'}
|
||||
icon={mdiPlus}
|
||||
onClick={createPage}
|
||||
disabled={isCreatingPage}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
elements.map((element) => {
|
||||
const shouldRender =
|
||||
selectedElementId === element.id ||
|
||||
(isElementVisibleOnCanvas(element) &&
|
||||
isElementReadyForCanvasRender(element));
|
||||
if (!shouldRender) return null;
|
||||
|
||||
const isNavDisabled =
|
||||
isNavigationElementType(element.type) &&
|
||||
(element.navDisabled ||
|
||||
Boolean(transitionPreview) ||
|
||||
isReverseBuffering);
|
||||
|
||||
return (
|
||||
<CanvasElementComponent
|
||||
key={element.id}
|
||||
element={element}
|
||||
isSelected={selectedElementId === element.id}
|
||||
isEditMode={isConstructorEditMode}
|
||||
isDisabled={isNavDisabled}
|
||||
onClick={() => onCanvasElementClick(element)}
|
||||
onMouseDown={(event) => onElementMouseDown(event, element.id)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
onGalleryCardClick={(cardIndex) =>
|
||||
handleGalleryCardClick(element, cardIndex)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</BackdropPortalProvider>
|
||||
</div>
|
||||
|
||||
{pages.length > 0 && hasEditorSelection && (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user