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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Background image */}
|
{/* Background image - z-1 keeps it below backdrop blur layer (z-5) */}
|
||||||
{backgroundImageUrl && (
|
{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:') ? (
|
{backgroundImageUrl.startsWith('blob:') ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<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 && (
|
{backgroundVideoUrl && (
|
||||||
<video
|
<video
|
||||||
key={`bg_video_${backgroundVideoUrl}`}
|
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}
|
src={backgroundVideoUrl}
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import CardBox from './CardBox';
|
|||||||
import { OfflineToggle } from './Offline/OfflineToggle';
|
import { OfflineToggle } from './Offline/OfflineToggle';
|
||||||
import RuntimeElement from './RuntimeElement';
|
import RuntimeElement from './RuntimeElement';
|
||||||
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
||||||
|
import { BackdropPortalProvider } from './BackdropPortal';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||||
import { usePageDataLoader } from '../hooks/usePageDataLoader';
|
import { usePageDataLoader } from '../hooks/usePageDataLoader';
|
||||||
@ -439,166 +440,175 @@ export default function RuntimePresentation({
|
|||||||
backgroundPosition: 'center',
|
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. */}
|
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */}
|
||||||
{backgroundImageUrl && !backgroundVideoUrl && (
|
{backgroundImageUrl && !backgroundVideoUrl && (
|
||||||
<div className='absolute inset-0 pointer-events-none'>
|
<div className='absolute inset-0 z-1 pointer-events-none'>
|
||||||
{backgroundImageUrl.startsWith('blob:') ? (
|
{backgroundImageUrl.startsWith('blob:') ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
key={backgroundImageUrl}
|
key={backgroundImageUrl}
|
||||||
src={backgroundImageUrl}
|
src={backgroundImageUrl}
|
||||||
alt=''
|
alt=''
|
||||||
className='absolute inset-0 w-full h-full object-cover'
|
className='absolute inset-0 w-full h-full object-cover'
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
setIsBackgroundReady(true);
|
setIsBackgroundReady(true);
|
||||||
pageSwitch.markBackgroundReady();
|
pageSwitch.markBackgroundReady();
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setIsBackgroundReady(true);
|
setIsBackgroundReady(true);
|
||||||
pageSwitch.markBackgroundReady();
|
pageSwitch.markBackgroundReady();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
key={backgroundImageUrl}
|
key={backgroundImageUrl}
|
||||||
src={backgroundImageUrl}
|
src={backgroundImageUrl}
|
||||||
alt=''
|
alt=''
|
||||||
fill
|
fill
|
||||||
sizes='100vw'
|
sizes='100vw'
|
||||||
className='object-cover'
|
className='object-cover'
|
||||||
priority
|
priority
|
||||||
unoptimized
|
unoptimized
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
setIsBackgroundReady(true);
|
setIsBackgroundReady(true);
|
||||||
pageSwitch.markBackgroundReady();
|
pageSwitch.markBackgroundReady();
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setIsBackgroundReady(true);
|
setIsBackgroundReady(true);
|
||||||
pageSwitch.markBackgroundReady();
|
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 */}
|
{/* Background video - z-1 keeps it below backdrop blur (z-5) */}
|
||||||
{pageSwitch.previousBgImageUrl &&
|
{backgroundVideoUrl && (
|
||||||
pageSwitch.isSwitching &&
|
<video
|
||||||
!pageSwitch.isNewBgReady && (
|
key={backgroundVideoUrl}
|
||||||
<div
|
className='absolute inset-0 z-1 w-full h-full object-cover'
|
||||||
className='absolute inset-0 pointer-events-none z-10'
|
src={backgroundVideoUrl}
|
||||||
style={{
|
autoPlay
|
||||||
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
|
loop
|
||||||
backgroundSize: 'cover',
|
muted
|
||||||
backgroundPosition: 'center',
|
playsInline
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Background video */}
|
{/* Page elements - z-10 ensures they appear above backdrop layer */}
|
||||||
{backgroundVideoUrl && (
|
<div className='absolute inset-0 z-10'>
|
||||||
<video
|
{pageElements.map((element: any) => (
|
||||||
key={backgroundVideoUrl}
|
<RuntimeElement
|
||||||
className='absolute inset-0 w-full h-full object-cover'
|
key={element.id}
|
||||||
src={backgroundVideoUrl}
|
element={element}
|
||||||
autoPlay
|
onClick={() => handleElementClick(element)}
|
||||||
loop
|
resolveUrl={resolveUrlWithBlob}
|
||||||
muted
|
onGalleryCardClick={(cardIndex) =>
|
||||||
playsInline
|
handleGalleryCardClick(element, cardIndex)
|
||||||
/>
|
}
|
||||||
)}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Page elements */}
|
{/* Controls: Offline toggle and Fullscreen button */}
|
||||||
<div className='absolute inset-0'>
|
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
|
||||||
{pageElements.map((element: any) => (
|
<OfflineToggle
|
||||||
<RuntimeElement
|
projectId={project?.id || null}
|
||||||
key={element.id}
|
projectSlug={projectSlug}
|
||||||
element={element}
|
projectName={project?.name}
|
||||||
onClick={() => handleElementClick(element)}
|
showLabel={false}
|
||||||
resolveUrl={resolveUrlWithBlob}
|
size='small'
|
||||||
onGalleryCardClick={(cardIndex) =>
|
|
||||||
handleGalleryCardClick(element, cardIndex)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<BaseButton
|
||||||
</div>
|
icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen}
|
||||||
|
color='info'
|
||||||
{/* Controls: Offline toggle and Fullscreen button */}
|
small
|
||||||
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
|
onClick={toggleFullscreen}
|
||||||
<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
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Gallery Carousel Overlay */}
|
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
|
||||||
{activeGalleryCarousel && (
|
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
|
||||||
<GalleryCarouselOverlay
|
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
|
||||||
cards={activeGalleryCarousel.element.galleryCards || []}
|
{transitionPreview && (
|
||||||
initialIndex={activeGalleryCarousel.initialIndex}
|
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
|
||||||
onClose={() => setActiveGalleryCarousel(null)}
|
<video
|
||||||
resolveUrl={resolveUrlWithBlob}
|
ref={transitionVideoRef}
|
||||||
prevIconUrl={
|
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
|
||||||
activeGalleryCarousel.element.galleryCarouselPrevIconUrl
|
style={{
|
||||||
}
|
opacity:
|
||||||
nextIconUrl={
|
transitionPhase === 'preparing' ||
|
||||||
activeGalleryCarousel.element.galleryCarouselNextIconUrl
|
isBuffering ||
|
||||||
}
|
isOverlayFadingOut
|
||||||
backIconUrl={
|
? 0
|
||||||
activeGalleryCarousel.element.galleryCarouselBackIconUrl
|
: 1,
|
||||||
}
|
}}
|
||||||
backLabel={
|
muted
|
||||||
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
|
playsInline
|
||||||
}
|
preload='auto'
|
||||||
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
|
disablePictureInPicture
|
||||||
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
|
/>
|
||||||
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
|
</div>
|
||||||
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
|
)}
|
||||||
backX={activeGalleryCarousel.element.galleryCarouselBackX}
|
|
||||||
backY={activeGalleryCarousel.element.galleryCarouselBackY}
|
{/* Gallery Carousel Overlay */}
|
||||||
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
|
{activeGalleryCarousel && (
|
||||||
prevHeight={activeGalleryCarousel.element.galleryCarouselPrevHeight}
|
<GalleryCarouselOverlay
|
||||||
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
|
cards={activeGalleryCarousel.element.galleryCards || []}
|
||||||
nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight}
|
initialIndex={activeGalleryCarousel.initialIndex}
|
||||||
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
|
onClose={() => setActiveGalleryCarousel(null)}
|
||||||
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
|
resolveUrl={resolveUrlWithBlob}
|
||||||
isEditMode={false}
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
buildGalleryCardGridStyle,
|
buildGalleryCardGridStyle,
|
||||||
GALLERY_SECTION_DEFAULTS,
|
GALLERY_SECTION_DEFAULTS,
|
||||||
} from '../../../lib/gallerySectionStyles';
|
} from '../../../lib/gallerySectionStyles';
|
||||||
|
import { useBackdropEffect } from '../../../hooks/useBackdropEffect';
|
||||||
|
|
||||||
interface GalleryElementProps {
|
interface GalleryElementProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
@ -69,17 +70,42 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
|
|||||||
|
|
||||||
// Build wrapper style from general element styles with gallery defaults
|
// Build wrapper style from general element styles with gallery defaults
|
||||||
const wrapperDefaults = GALLERY_SECTION_DEFAULTS.wrapper;
|
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(
|
const wrapperStyle: CSSProperties = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
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,
|
padding: style.padding || wrapperDefaults.padding,
|
||||||
borderRadius: style.borderRadius || wrapperDefaults.borderRadius,
|
borderRadius: style.borderRadius || wrapperDefaults.borderRadius,
|
||||||
border: style.border,
|
border: style.border,
|
||||||
gap: style.gap || wrapperDefaults.gap,
|
gap: style.gap || wrapperDefaults.gap,
|
||||||
backdropFilter: wrapperDefaults.backdropFilter,
|
|
||||||
WebkitBackdropFilter: wrapperDefaults.backdropFilter,
|
|
||||||
// Visual properties that should apply to the wrapper, not outer positioning div
|
// Visual properties that should apply to the wrapper, not outer positioning div
|
||||||
boxShadow: style.boxShadow,
|
boxShadow: style.boxShadow,
|
||||||
opacity: style.opacity,
|
opacity: style.opacity,
|
||||||
@ -90,7 +116,9 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
|
|||||||
lineHeight: style.lineHeight,
|
lineHeight: style.lineHeight,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
style.backgroundColor,
|
isPortalAvailable,
|
||||||
|
backdropBackgroundColor,
|
||||||
|
backdropFilterValue,
|
||||||
style.padding,
|
style.padding,
|
||||||
style.borderRadius,
|
style.borderRadius,
|
||||||
style.border,
|
style.border,
|
||||||
@ -123,7 +151,7 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={outerStyle}>
|
<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: image takes priority, otherwise render text */}
|
||||||
{/* Header styles (border, borderRadius, dimensions) apply to both image and text modes */}
|
{/* Header styles (border, borderRadius, dimensions) apply to both image and text modes */}
|
||||||
{headerImageUrl ? (
|
{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 CanvasElementComponent from '../components/Constructor/CanvasElement';
|
||||||
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
|
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
|
||||||
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
||||||
|
import { BackdropPortalProvider } from '../components/BackdropPortal';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||||
@ -1305,61 +1306,68 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
className='absolute inset-0 bg-black overflow-clip'
|
className='absolute inset-0 bg-black overflow-clip'
|
||||||
style={canvasBackgroundStyle}
|
style={canvasBackgroundStyle}
|
||||||
>
|
>
|
||||||
<CanvasBackground
|
<BackdropPortalProvider>
|
||||||
backgroundImageUrl={backgroundImageSrc}
|
<CanvasBackground
|
||||||
backgroundVideoUrl={backgroundVideoSrc}
|
backgroundImageUrl={backgroundImageSrc}
|
||||||
backgroundAudioUrl={backgroundAudioSrc}
|
backgroundVideoUrl={backgroundVideoSrc}
|
||||||
previousBgImageUrl={pageSwitch.previousBgImageUrl}
|
backgroundAudioUrl={backgroundAudioSrc}
|
||||||
isSwitching={pageSwitch.isSwitching}
|
previousBgImageUrl={pageSwitch.previousBgImageUrl}
|
||||||
isNewBgReady={pageSwitch.isNewBgReady}
|
isSwitching={pageSwitch.isSwitching}
|
||||||
onBackgroundReady={() => pageSwitch.markBackgroundReady()}
|
isNewBgReady={pageSwitch.isNewBgReady}
|
||||||
/>
|
onBackgroundReady={() => pageSwitch.markBackgroundReady()}
|
||||||
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
{/* Elements container - z-10 ensures they appear above backdrop layer */}
|
||||||
<div className='absolute inset-0 flex items-center justify-center'>
|
<div className='absolute inset-0 z-10'>
|
||||||
<p className='text-sm text-gray-500'>Loading constructor...</p>
|
{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>
|
</div>
|
||||||
) : pages.length === 0 ? (
|
</BackdropPortalProvider>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{pages.length > 0 && hasEditorSelection && (
|
{pages.length > 0 && hasEditorSelection && (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user