fixed blur issue

This commit is contained in:
Dmitri 2026-04-03 13:17:43 +04:00
parent 61e707f2ba
commit ce68bad8ba
7 changed files with 562 additions and 210 deletions

File diff suppressed because one or more lines are too long

View 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;

View File

@ -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

View File

@ -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>
</>
);

View File

@ -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 ? (

View 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;

View File

@ -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 && (