fixed carusel adaptivity issue

This commit is contained in:
Dmitri 2026-04-14 17:45:31 +04:00
parent 4a61fd1a69
commit 4b7fed5914
7 changed files with 203 additions and 131 deletions

View File

@ -35,6 +35,8 @@ interface CanvasElementProps {
x: number, x: number,
y: number, y: number,
) => void; ) => void;
/** Letterbox styles for constraining fullscreen elements to canvas bounds */
letterboxStyles?: React.CSSProperties;
} }
const CanvasElement: React.FC<CanvasElementProps> = ({ const CanvasElement: React.FC<CanvasElementProps> = ({
@ -47,6 +49,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
resolveUrl, resolveUrl,
onGalleryCardClick, onGalleryCardClick,
onCarouselButtonPositionChange, onCarouselButtonPositionChange,
letterboxStyles,
}) => { }) => {
// Extract effect properties from element // Extract effect properties from element
const effectProperties: Partial<ElementEffectProperties> = { const effectProperties: Partial<ElementEffectProperties> = {
@ -73,10 +76,16 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
isEditMode ? {} : effectProperties, isEditMode ? {} : effectProperties,
); );
// Clamp position to canvas bounds (0-100%)
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const xClamped = clamp(element.xPercent ?? 50, 0, 100);
const yClamped = clamp(element.yPercent ?? 50, 0, 100);
// Build base position style // Build base position style
let positionStyle: React.CSSProperties = { let positionStyle: React.CSSProperties = {
left: `${element.xPercent}%`, left: `${xClamped}%`,
top: `${element.yPercent}%`, top: `${yClamped}%`,
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
// Reset button defaults to let UiElementRenderer control styling // Reset button defaults to let UiElementRenderer control styling
background: 'transparent', background: 'transparent',
@ -130,6 +139,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
isDisabled={isDisabled} isDisabled={isDisabled}
onGalleryCardClick={onGalleryCardClick} onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange} onCarouselButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles}
/> />
</button> </button>
); );

View File

@ -24,16 +24,24 @@ interface RuntimeElementProps {
resolveUrl?: (url: string | undefined) => string; resolveUrl?: (url: string | undefined) => string;
/** Gallery card click handler */ /** Gallery card click handler */
onGalleryCardClick?: (cardIndex: number) => void; onGalleryCardClick?: (cardIndex: number) => void;
/** Letterbox styles for constraining fullscreen elements to canvas bounds */
letterboxStyles?: React.CSSProperties;
} }
// Clamp position to canvas bounds (0-100%)
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const RuntimeElement: React.FC<RuntimeElementProps> = ({ const RuntimeElement: React.FC<RuntimeElementProps> = ({
element, element,
onClick, onClick,
resolveUrl, resolveUrl,
onGalleryCardClick, onGalleryCardClick,
letterboxStyles,
}) => { }) => {
const xPercent = element.xPercent ?? 0; // Clamp coordinates to canvas bounds
const yPercent = element.yPercent ?? 0; const xPercent = clamp(element.xPercent ?? 50, 0, 100);
const yPercent = clamp(element.yPercent ?? 50, 0, 100);
const rotation = element.rotation ?? 0; const rotation = element.rotation ?? 0;
// Extract effect properties from element // Extract effect properties from element
@ -101,6 +109,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
element={element} element={element}
resolveUrl={resolveUrl} resolveUrl={resolveUrl}
onGalleryCardClick={onGalleryCardClick} onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles}
/> />
</div> </div>
); );

View File

@ -713,6 +713,7 @@ export default function RuntimePresentation({
onGalleryCardClick={(cardIndex) => onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex) handleGalleryCardClick(element, cardIndex)
} }
letterboxStyles={letterboxStyles}
/> />
))} ))}
</div> </div>
@ -794,6 +795,7 @@ export default function RuntimePresentation({
backHeight={ backHeight={
activeGalleryCarousel.element.galleryCarouselBackHeight activeGalleryCarousel.element.galleryCarouselBackHeight
} }
letterboxStyles={letterboxStyles}
isEditMode={false} isEditMode={false}
/> />
)} )}

View File

@ -44,6 +44,8 @@ interface GalleryCarouselOverlayProps {
x: number, x: number,
y: number, y: number,
) => void; ) => void;
// Letterbox styles for constraining overlay to canvas bounds
letterboxStyles?: React.CSSProperties;
} }
const clamp = (value: number, min: number, max: number) => const clamp = (value: number, min: number, max: number) =>
@ -72,28 +74,38 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
backHeight, backHeight,
isEditMode = false, isEditMode = false,
onButtonPositionChange, onButtonPositionChange,
letterboxStyles,
}) => { }) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [draggingButton, setDraggingButton] = useState< const [draggingButton, setDraggingButton] = useState<
'prev' | 'next' | 'back' | null 'prev' | 'next' | 'back' | null
>(null); >(null);
// Clamp positions to canvas bounds (0-100%)
const [positions, setPositions] = useState({ const [positions, setPositions] = useState({
prevX, prevX: clamp(prevX, 0, 100),
prevY, prevY: clamp(prevY, 0, 100),
nextX, nextX: clamp(nextX, 0, 100),
nextY, nextY: clamp(nextY, 0, 100),
backX, backX: clamp(backX, 0, 100),
backY, backY: clamp(backY, 0, 100),
}); });
const overlayRef = useRef<HTMLDivElement>(null); const overlayRef = useRef<HTMLDivElement>(null);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const touchStartRef = useRef<{ x: number; y: number } | null>(null); const touchStartRef = useRef<{ x: number; y: number } | null>(null);
const positionsRef = useRef(positions); const positionsRef = useRef(positions);
positionsRef.current = positions; positionsRef.current = positions;
// Update positions when props change (e.g., when element is re-selected) // Update positions when props change (clamped to canvas bounds)
useEffect(() => { useEffect(() => {
setPositions({ prevX, prevY, nextX, nextY, backX, backY }); setPositions({
prevX: clamp(prevX, 0, 100),
prevY: clamp(prevY, 0, 100),
nextX: clamp(nextX, 0, 100),
nextY: clamp(nextY, 0, 100),
backX: clamp(backX, 0, 100),
backY: clamp(backY, 0, 100),
});
}, [prevX, prevY, nextX, nextY, backX, backY]); }, [prevX, prevY, nextX, nextY, backX, backY]);
// Navigation handlers // Navigation handlers
@ -169,10 +181,16 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
if (!isEditMode || !draggingButton) return; if (!isEditMode || !draggingButton) return;
const handleMove = (e: MouseEvent) => { const handleMove = (e: MouseEvent) => {
const x = (e.clientX / window.innerWidth) * 100; // Calculate position relative to canvas container (strict - no viewport fallback)
const y = (e.clientY / window.innerHeight) * 100; const container = canvasContainerRef.current;
const clampedX = clamp(x, 2, 98); if (!container) return;
const clampedY = clamp(y, 2, 98);
const rect = container.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
// Clamp to canvas bounds (0-100%)
const clampedX = clamp(x, 0, 100);
const clampedY = clamp(y, 0, 100);
setPositions((prev) => { setPositions((prev) => {
// Prev and next buttons share the same Y coordinate // Prev and next buttons share the same Y coordinate
@ -346,52 +364,59 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
{/* Fullscreen image */} {/* Inner container constrained to canvas bounds - no conflicting CSS classes */}
{imageUrl && ( <div
// eslint-disable-next-line @next/next/no-img-element ref={canvasContainerRef}
<img className='overflow-hidden'
src={imageUrl} style={letterboxStyles || { position: 'absolute', inset: 0 }}
alt={currentCard?.title || ''} >
className='absolute inset-0 h-full w-full object-cover' {/* Fullscreen image */}
draggable={false} {imageUrl && (
/> // eslint-disable-next-line @next/next/no-img-element
)} <img
src={imageUrl}
alt={currentCard?.title || ''}
className='absolute inset-0 h-full w-full object-contain'
draggable={false}
/>
)}
{/* Prev button */} {/* Prev button */}
{renderNavButton( {renderNavButton(
'prev', 'prev',
positions.prevX, positions.prevX,
positions.prevY, positions.prevY,
prevIconUrl, prevIconUrl,
mdiChevronLeft, mdiChevronLeft,
undefined, undefined,
prevWidth, prevWidth,
prevHeight, prevHeight,
)} )}
{/* Next button */} {/* Next button */}
{renderNavButton( {renderNavButton(
'next', 'next',
positions.nextX, positions.nextX,
positions.nextY, positions.nextY,
nextIconUrl, nextIconUrl,
mdiChevronRight, mdiChevronRight,
undefined, undefined,
nextWidth, nextWidth,
nextHeight, nextHeight,
)} )}
{/* Back button */} {/* Back button */}
{renderNavButton( {renderNavButton(
'back', 'back',
positions.backX, positions.backX,
positions.backY, positions.backY,
backIconUrl, backIconUrl,
mdiArrowLeft, mdiArrowLeft,
backLabel, backLabel,
backWidth, backWidth,
backHeight, backHeight,
)} )}
</div>
</div> </div>
); );
}; };

View File

@ -51,6 +51,8 @@ export interface UiElementRendererProps {
x: number, x: number,
y: number, y: number,
) => void; ) => void;
// Letterbox styles for constraining fullscreen elements to canvas bounds
letterboxStyles?: React.CSSProperties;
} }
/** /**
@ -67,6 +69,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
isDisabled = false, isDisabled = false,
onGalleryCardClick, onGalleryCardClick,
onCarouselButtonPositionChange, onCarouselButtonPositionChange,
letterboxStyles,
}) => { }) => {
const { className, style } = useElementWrapperStyle({ const { className, style } = useElementWrapperStyle({
element, element,
@ -97,6 +100,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
{...commonProps} {...commonProps}
isEditMode={isEditMode} isEditMode={isEditMode}
onButtonPositionChange={onCarouselButtonPositionChange} onButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles}
/> />
); );
} }

View File

@ -42,6 +42,8 @@ interface CarouselElementProps {
x: number, x: number,
y: number, y: number,
) => void; ) => void;
// Letterbox styles for constraining full-width carousel to canvas bounds
letterboxStyles?: CSSProperties;
} }
const clamp = (value: number, min: number, max: number) => const clamp = (value: number, min: number, max: number) =>
@ -54,6 +56,7 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
style, style,
isEditMode = false, isEditMode = false,
onButtonPositionChange, onButtonPositionChange,
letterboxStyles,
}) => { }) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const slides: CarouselSlide[] = element.carouselSlides || []; const slides: CarouselSlide[] = element.carouselSlides || [];
@ -65,11 +68,12 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>( const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>(
null, null,
); );
// Clamp positions to canvas bounds (0-100%)
const [positions, setPositions] = useState({ const [positions, setPositions] = useState({
prevX: element.carouselPrevX ?? 5, prevX: clamp(element.carouselPrevX ?? 5, 0, 100),
prevY: element.carouselPrevY ?? 50, prevY: clamp(element.carouselPrevY ?? 50, 0, 100),
nextX: element.carouselNextX ?? 95, nextX: clamp(element.carouselNextX ?? 95, 0, 100),
nextY: element.carouselNextY ?? 50, nextY: clamp(element.carouselNextY ?? 50, 0, 100),
}); });
// Touch swipe ref // Touch swipe ref
@ -78,13 +82,13 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
const positionsRef = useRef(positions); const positionsRef = useRef(positions);
positionsRef.current = positions; positionsRef.current = positions;
// Update positions when props change // Update positions when props change (clamped to canvas bounds)
useEffect(() => { useEffect(() => {
setPositions({ setPositions({
prevX: element.carouselPrevX ?? 5, prevX: clamp(element.carouselPrevX ?? 5, 0, 100),
prevY: element.carouselPrevY ?? 50, prevY: clamp(element.carouselPrevY ?? 50, 0, 100),
nextX: element.carouselNextX ?? 95, nextX: clamp(element.carouselNextX ?? 95, 0, 100),
nextY: element.carouselNextY ?? 50, nextY: clamp(element.carouselNextY ?? 50, 0, 100),
}); });
}, [ }, [
element.carouselPrevX, element.carouselPrevX,
@ -199,10 +203,16 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
if (!isEditMode || !draggingButton) return; if (!isEditMode || !draggingButton) return;
const handleMove = (e: MouseEvent) => { const handleMove = (e: MouseEvent) => {
const x = (e.clientX / window.innerWidth) * 100; // Calculate position relative to canvas container (strict - no viewport fallback)
const y = (e.clientY / window.innerHeight) * 100; const container = overlayRef.current;
const clampedX = clamp(x, 2, 98); if (!container) return;
const clampedY = clamp(y, 2, 98);
const rect = container.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
// Clamp to canvas bounds (0-100%)
const clampedX = clamp(x, 0, 100);
const clampedY = clamp(y, 0, 100);
setPositions((prev) => ({ setPositions((prev) => ({
...prev, ...prev,
@ -240,11 +250,8 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
}, [isEditMode, draggingButton, onButtonPositionChange]); }, [isEditMode, draggingButton, onButtonPositionChange]);
// Convert numeric value to canvas units for responsive scaling // Convert numeric value to canvas units for responsive scaling
// Previously used vw/vh but now uses canvas units (--cu) for uniform scaling // All dimensions use canvas units (--cu) for uniform scaling within project bounds
const toCanvasUnit = ( const toCanvasUnit = (value?: string): string | undefined => {
value?: string,
dimension: 'width' | 'height' = 'width',
): string | undefined => {
if (!value || value.trim() === '') return undefined; if (!value || value.trim() === '') return undefined;
const trimmed = value.trim(); const trimmed = value.trim();
// If value already uses canvas units or calc, preserve it // If value already uses canvas units or calc, preserve it
@ -272,10 +279,8 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
return toCU(num); return toCU(num);
}; };
// Alias for backward compatibility
const toViewportUnit = toCanvasUnit;
// Render navigation button for full-width mode // Render navigation button for full-width mode
// Coordinates are clamped to 0-100% to stay within canvas bounds
const renderNavButton = ( const renderNavButton = (
type: 'prev' | 'next', type: 'prev' | 'next',
x: number, x: number,
@ -287,8 +292,12 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
) => { ) => {
const isDragging = draggingButton === type; const isDragging = draggingButton === type;
const hasCustomIcon = iconUrl && iconUrl.trim() !== ''; const hasCustomIcon = iconUrl && iconUrl.trim() !== '';
const widthValue = toCanvasUnit(buttonWidth, 'width'); const widthValue = toCanvasUnit(buttonWidth);
const heightValue = toCanvasUnit(buttonHeight, 'height'); const heightValue = toCanvasUnit(buttonHeight);
// Clamp coordinates to canvas bounds (0-100%)
const clampedX = clamp(x, 0, 100);
const clampedY = clamp(y, 0, 100);
// Navigation-style: custom icon fills button (no backdrop) // Navigation-style: custom icon fills button (no backdrop)
const useNavigationStyle = hasCustomIcon && (widthValue || heightValue); const useNavigationStyle = hasCustomIcon && (widthValue || heightValue);
@ -302,8 +311,8 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
: 'cursor-pointer hover:scale-105' : 'cursor-pointer hover:scale-105'
} ${isDragging ? 'scale-110 z-[60]' : ''}`} } ${isDragging ? 'scale-110 z-[60]' : ''}`}
style={{ style={{
left: `${x}%`, left: `${clampedX}%`,
top: `${y}%`, top: `${clampedY}%`,
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
...(widthValue && { width: widthValue }), ...(widthValue && { width: widthValue }),
...(heightValue && { height: heightValue }), ...(heightValue && { height: heightValue }),
@ -342,60 +351,71 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
// Full-width carousel - two layers: // Full-width carousel - two layers:
// 1. Background image layer at z-10 (behind canvas z-[46]) // 1. Background image layer at z-10 (behind canvas z-[46])
// 2. Navigation/caption layer at z-[47] (above canvas z-[46], below UI controls z-50) // 2. Navigation/caption layer at z-[47] (above canvas z-[46], below UI controls z-50)
// Both layers use letterboxStyles to constrain content to canvas bounds
const fullWidthBackground = ( const fullWidthBackground = (
<div className='fixed inset-0 z-10 overflow-hidden bg-black pointer-events-none'> <div className='fixed inset-0 z-10 overflow-hidden bg-black pointer-events-none'>
{currentSlide?.imageUrl && ( {/* Inner container respects letterbox dimensions - overflow:hidden clips content to canvas */}
// eslint-disable-next-line @next/next/no-img-element <div
<img className='overflow-hidden'
src={resolve(currentSlide.imageUrl)} style={letterboxStyles || { position: 'absolute', inset: 0 }}
alt={currentSlide.caption || 'Carousel slide'} >
className='absolute inset-0 w-full h-full object-cover' {currentSlide?.imageUrl && (
draggable={false} // eslint-disable-next-line @next/next/no-img-element
/> <img
)} src={resolve(currentSlide.imageUrl)}
alt={currentSlide.caption || 'Carousel slide'}
className='absolute inset-0 w-full h-full object-contain'
draggable={false}
/>
)}
</div>
</div> </div>
); );
const fullWidthControls = ( const fullWidthControls = (
<div <div className='fixed inset-0 z-[47] pointer-events-none'>
ref={overlayRef} {/* Inner container respects letterbox dimensions - overflow:hidden clips content to canvas */}
className='fixed inset-0 z-[47] pointer-events-none' <div
onTouchStart={handleTouchStart} ref={overlayRef}
onTouchEnd={handleTouchEnd} className='overflow-hidden'
> style={letterboxStyles || { position: 'absolute', inset: 0 }}
{/* Navigation buttons */} onTouchStart={handleTouchStart}
{showNavigation && ( onTouchEnd={handleTouchEnd}
<> >
{renderNavButton( {/* Navigation buttons */}
'prev', {showNavigation && (
positions.prevX, <>
positions.prevY, {renderNavButton(
element.carouselPrevIconUrl, 'prev',
mdiChevronLeft, positions.prevX,
element.carouselPrevWidth, positions.prevY,
element.carouselPrevHeight, element.carouselPrevIconUrl,
)} mdiChevronLeft,
{renderNavButton( element.carouselPrevWidth,
'next', element.carouselPrevHeight,
positions.nextX, )}
positions.nextY, {renderNavButton(
element.carouselNextIconUrl, 'next',
mdiChevronRight, positions.nextX,
element.carouselNextWidth, positions.nextY,
element.carouselNextHeight, element.carouselNextIconUrl,
)} mdiChevronRight,
</> element.carouselNextWidth,
)} element.carouselNextHeight,
)}
</>
)}
{/* Caption */} {/* Caption */}
{currentSlide?.caption && ( {currentSlide?.caption && (
<div <div
className='absolute bottom-8 left-0 right-0 text-center text-white text-lg px-4' className='absolute bottom-8 left-0 right-0 text-center text-white text-lg px-4'
style={captionFontStyle} style={captionFontStyle}
> >
{currentSlide.caption} {currentSlide.caption}
</div> </div>
)} )}
</div>
</div> </div>
); );

View File

@ -1569,6 +1569,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
y, y,
) )
} }
letterboxStyles={letterboxStyles}
/> />
); );
}) })
@ -1648,6 +1649,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight} nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight}
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth} backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight} backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
letterboxStyles={letterboxStyles}
isEditMode={isConstructorEditMode} isEditMode={isConstructorEditMode}
onButtonPositionChange={handleGalleryCarouselButtonPositionChange} onButtonPositionChange={handleGalleryCarouselButtonPositionChange}
/> />