improved carousel UI element

This commit is contained in:
Dmitri 2026-04-03 17:16:22 +04:00
parent fec8864e07
commit fd57a3bf10
8 changed files with 658 additions and 43 deletions

File diff suppressed because one or more lines are too long

View File

@ -29,6 +29,12 @@ interface CanvasElementProps {
resolveUrl?: (url: string | undefined) => string;
/** Gallery card click handler */
onGalleryCardClick?: (cardIndex: number) => void;
/** Carousel button position change handler (constructor edit mode) */
onCarouselButtonPositionChange?: (
button: 'prev' | 'next',
x: number,
y: number,
) => void;
}
const CanvasElement: React.FC<CanvasElementProps> = ({
@ -40,6 +46,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
onMouseDown,
resolveUrl,
onGalleryCardClick,
onCarouselButtonPositionChange,
}) => {
// Extract effect properties from element
const effectProperties: Partial<ElementEffectProperties> = {
@ -122,6 +129,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
isEditMode={isEditMode}
isDisabled={isDisabled}
onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
/>
</button>
);

View File

@ -566,6 +566,21 @@ export function ElementEditorPanel({
carouselCaptionFontFamily={
selectedElement.carouselCaptionFontFamily || ''
}
carouselFullWidth={
selectedElement.carouselFullWidth || false
}
carouselPrevWidth={
selectedElement.carouselPrevWidth || ''
}
carouselPrevHeight={
selectedElement.carouselPrevHeight || ''
}
carouselNextWidth={
selectedElement.carouselNextWidth || ''
}
carouselNextHeight={
selectedElement.carouselNextHeight || ''
}
iconAssetOptions={iconAssetOptions}
imageAssetOptions={imageAssetOptions}
onUpdateElement={onUpdateElement}

View File

@ -2,7 +2,7 @@
* CarouselSettingsSectionCompact
*
* Compact carousel element settings for constructor sidebar.
* Navigation icons and slide management.
* Navigation icons, dimensions, and slide management.
*/
import React from 'react';
@ -15,12 +15,22 @@ interface CarouselSettingsSectionCompactProps {
carouselPrevIconUrl: string;
carouselNextIconUrl: string;
carouselCaptionFontFamily: string;
carouselFullWidth: boolean;
carouselPrevWidth: string;
carouselPrevHeight: string;
carouselNextWidth: string;
carouselNextHeight: string;
iconAssetOptions: AssetOption[];
imageAssetOptions: AssetOption[];
onUpdateElement: (patch: {
carouselPrevIconUrl?: string;
carouselNextIconUrl?: string;
carouselCaptionFontFamily?: string;
carouselFullWidth?: boolean;
carouselPrevWidth?: string;
carouselPrevHeight?: string;
carouselNextWidth?: string;
carouselNextHeight?: string;
}) => void;
onAddSlide: () => void;
onUpdateSlide: (slideId: string, patch: Partial<CarouselSlide>) => void;
@ -34,6 +44,11 @@ const CarouselSettingsSectionCompact: React.FC<
carouselPrevIconUrl,
carouselNextIconUrl,
carouselCaptionFontFamily,
carouselFullWidth,
carouselPrevWidth,
carouselPrevHeight,
carouselNextWidth,
carouselNextHeight,
iconAssetOptions,
imageAssetOptions,
onUpdateElement,
@ -43,6 +58,25 @@ const CarouselSettingsSectionCompact: React.FC<
}) => {
return (
<div className='space-y-2'>
{/* Full-width mode toggle */}
<div className='flex items-center gap-2 py-1'>
<input
type='checkbox'
id='carouselFullWidth'
checked={carouselFullWidth}
onChange={(e) =>
onUpdateElement({ carouselFullWidth: e.target.checked })
}
className='rounded border-gray-300'
/>
<label
htmlFor='carouselFullWidth'
className='text-[11px] text-gray-700'
>
Full-width mode (background layer)
</label>
</div>
<div className='rounded border border-gray-200 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-700'>
Navigation icons
@ -67,6 +101,34 @@ const CarouselSettingsSectionCompact: React.FC<
))}
</select>
{/* Prev icon dimensions (shown when prev icon is set) */}
{carouselPrevIconUrl && (
<div className='flex gap-1'>
<input
type='number'
step='0.25'
min='0'
className='w-1/2 rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='W (vw)'
value={carouselPrevWidth}
onChange={(e) =>
onUpdateElement({ carouselPrevWidth: e.target.value })
}
/>
<input
type='number'
step='0.25'
min='0'
className='w-1/2 rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='H (vh)'
value={carouselPrevHeight}
onChange={(e) =>
onUpdateElement({ carouselPrevHeight: e.target.value })
}
/>
</div>
)}
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={carouselNextIconUrl}
@ -86,6 +148,34 @@ const CarouselSettingsSectionCompact: React.FC<
))}
</select>
{/* Next icon dimensions (shown when next icon is set) */}
{carouselNextIconUrl && (
<div className='flex gap-1'>
<input
type='number'
step='0.25'
min='0'
className='w-1/2 rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='W (vw)'
value={carouselNextWidth}
onChange={(e) =>
onUpdateElement({ carouselNextWidth: e.target.value })
}
/>
<input
type='number'
step='0.25'
min='0'
className='w-1/2 rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='H (vh)'
value={carouselNextHeight}
onChange={(e) =>
onUpdateElement({ carouselNextHeight: e.target.value })
}
/>
</div>
)}
<div>
<label className='text-[10px] text-gray-600'>Caption font:</label>
<select
@ -103,6 +193,13 @@ const CarouselSettingsSectionCompact: React.FC<
))}
</select>
</div>
{carouselFullWidth && (
<p className='text-[10px] text-gray-500 mt-1'>
In full-width mode: set icon + dimensions for navigation-style
buttons. Drag to reposition in editor.
</p>
)}
</div>
<div className='flex items-center justify-between'>

View File

@ -45,6 +45,12 @@ export interface UiElementRendererProps {
isDisabled?: boolean;
// Gallery carousel callback
onGalleryCardClick?: (cardIndex: number) => void;
// Carousel-specific callback for button position changes (constructor only)
onCarouselButtonPositionChange?: (
button: 'prev' | 'next',
x: number,
y: number,
) => void;
}
/**
@ -60,6 +66,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
isEditMode = false,
isDisabled = false,
onGalleryCardClick,
onCarouselButtonPositionChange,
}) => {
const { className, style } = useElementWrapperStyle({
element,
@ -85,7 +92,13 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
return <DescriptionElement {...commonProps} />;
}
if (isCarouselElementType(element.type)) {
return <CarouselElement {...commonProps} />;
return (
<CarouselElement
{...commonProps}
isEditMode={isEditMode}
onButtonPositionChange={onCarouselButtonPositionChange}
/>
);
}
if (isVideoPlayerElementType(element.type)) {
return <VideoPlayerElement {...commonProps} />;

View File

@ -2,11 +2,29 @@
* CarouselElement Component
*
* Carousel element - slideshow of images with navigation.
* Renders with unified wrapper styling + content.
* Supports two modes:
* - Normal: inline carousel with prev/next navigation
* - Full-width: covers full viewport as background layer (z-10)
* Other elements can be positioned above the carousel
*
* Full-width mode features:
* - Arrow key navigation (ArrowLeft/ArrowRight)
* - Touch swipe support
* - Draggable button positioning (constructor edit mode)
* - Navigation-style rendering when custom icons with dimensions are set
*/
import React, { useMemo } from 'react';
import React, {
useState,
useMemo,
useCallback,
useEffect,
useRef,
} from 'react';
import { createPortal } from 'react-dom';
import type { CSSProperties } from 'react';
import Icon from '@mdi/react';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
@ -16,17 +34,93 @@ interface CarouselElementProps {
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
isEditMode?: boolean;
// Constructor-only: callback when button is dragged to new position
onButtonPositionChange?: (
button: 'prev' | 'next',
x: number,
y: number,
) => void;
}
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const CarouselElement: React.FC<CarouselElementProps> = ({
element,
resolveUrl,
className,
style,
isEditMode = false,
onButtonPositionChange,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const slides: CarouselSlide[] = element.carouselSlides || [];
const firstSlide = slides[0];
const [currentIndex, setCurrentIndex] = useState(0);
const currentSlide = slides[currentIndex] || slides[0];
const isFullWidth = element.carouselFullWidth || false;
// Drag state (constructor edit mode only)
const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>(
null,
);
const [positions, setPositions] = useState({
prevX: element.carouselPrevX ?? 5,
prevY: element.carouselPrevY ?? 50,
nextX: element.carouselNextX ?? 95,
nextY: element.carouselNextY ?? 50,
});
// Touch swipe ref
const touchStartRef = useRef<{ x: number } | null>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const positionsRef = useRef(positions);
positionsRef.current = positions;
// Update positions when props change
useEffect(() => {
setPositions({
prevX: element.carouselPrevX ?? 5,
prevY: element.carouselPrevY ?? 50,
nextX: element.carouselNextX ?? 95,
nextY: element.carouselNextY ?? 50,
});
}, [
element.carouselPrevX,
element.carouselPrevY,
element.carouselNextX,
element.carouselNextY,
]);
// Navigation handlers (no event parameter for keyboard/swipe use)
const goToPrev = useCallback(() => {
if (slides.length === 0) return;
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length);
}, [slides.length]);
const goToNext = useCallback(() => {
if (slides.length === 0) return;
setCurrentIndex((prev) => (prev + 1) % slides.length);
}, [slides.length]);
// Click handlers for buttons (with event propagation control)
const handlePrevClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (isEditMode) return;
goToPrev();
},
[goToPrev, isEditMode],
);
const handleNextClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (isEditMode) return;
goToNext();
},
[goToNext, isEditMode],
);
// Resolve font key to full CSS style (including fontStretch for condensed variants)
const captionFontStyle = useMemo(() => {
@ -36,50 +130,362 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
return font ? getFontStyle(font) : { fontFamily: fontKey };
}, [element.carouselCaptionFontFamily]);
const showNavigation = slides.length > 1;
// Track if we're in browser for portal rendering
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
// Arrow key navigation (full-width mode only, runtime only)
useEffect(() => {
if (!isFullWidth || isEditMode) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
goToPrev();
} else if (e.key === 'ArrowRight') {
goToNext();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isFullWidth, isEditMode, goToPrev, goToNext]);
// Touch swipe handling (full-width mode, runtime only)
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if (isEditMode) return;
touchStartRef.current = { x: e.touches[0].clientX };
},
[isEditMode],
);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (isEditMode || !touchStartRef.current) return;
const deltaX = e.changedTouches[0].clientX - touchStartRef.current.x;
const threshold = 50;
if (Math.abs(deltaX) > threshold) {
if (deltaX > 0) {
goToPrev();
} else {
goToNext();
}
}
touchStartRef.current = null;
},
[isEditMode, goToPrev, goToNext],
);
// Draggable button handling (constructor edit mode only)
const handleButtonDragStart = useCallback(
(button: 'prev' | 'next', e: React.MouseEvent) => {
if (!isEditMode) return;
e.preventDefault();
e.stopPropagation();
setDraggingButton(button);
},
[isEditMode],
);
useEffect(() => {
if (!isEditMode || !draggingButton) return;
const handleMove = (e: MouseEvent) => {
const x = (e.clientX / window.innerWidth) * 100;
const y = (e.clientY / window.innerHeight) * 100;
const clampedX = clamp(x, 2, 98);
const clampedY = clamp(y, 2, 98);
setPositions((prev) => ({
...prev,
[`${draggingButton}X`]: clampedX,
// Prev and next buttons share same Y coordinate
prevY: clampedY,
nextY: clampedY,
}));
};
const handleUp = () => {
if (onButtonPositionChange && draggingButton) {
const current = positionsRef.current;
onButtonPositionChange(
draggingButton,
current[`${draggingButton}X`],
current[`${draggingButton}Y`],
);
// Sync other button's Y position
if (draggingButton === 'prev') {
onButtonPositionChange('next', current.nextX, current.prevY);
} else {
onButtonPositionChange('prev', current.prevX, current.nextY);
}
}
setDraggingButton(null);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
}, [isEditMode, draggingButton, onButtonPositionChange]);
// Convert numeric value to viewport units (vw for width, vh for height)
const toViewportUnit = (
value?: string,
unit: 'vw' | 'vh' = 'vw',
): string | undefined => {
if (!value || value.trim() === '') return undefined;
const trimmed = value.trim();
// If value already has a unit, preserve it
if (/[a-z%]+$/i.test(trimmed)) return trimmed;
const num = parseFloat(trimmed);
if (!Number.isFinite(num) || num <= 0) return undefined;
return `${num}${unit}`;
};
// Render navigation button for full-width mode
const renderNavButton = (
type: 'prev' | 'next',
x: number,
y: number,
iconUrl?: string,
defaultIcon?: string,
buttonWidth?: string,
buttonHeight?: string,
) => {
const isDragging = draggingButton === type;
const hasCustomIcon = iconUrl && iconUrl.trim() !== '';
const widthValue = toViewportUnit(buttonWidth, 'vw');
const heightValue = toViewportUnit(buttonHeight, 'vh');
// Navigation-style: custom icon fills button (no backdrop)
const useNavigationStyle = hasCustomIcon && (widthValue || heightValue);
return (
<button
type='button'
className={`absolute flex items-center justify-center transition-transform pointer-events-auto ${
isEditMode
? 'cursor-move hover:scale-110'
: 'cursor-pointer hover:scale-105'
} ${isDragging ? 'scale-110 z-[60]' : ''}`}
style={{
left: `${x}%`,
top: `${y}%`,
transform: 'translate(-50%, -50%)',
...(widthValue && { width: widthValue }),
...(heightValue && { height: heightValue }),
}}
onClick={type === 'prev' ? handlePrevClick : handleNextClick}
onMouseDown={(e) => handleButtonDragStart(type, e)}
>
{useNavigationStyle ? (
// Navigation-style: icon fills button, no backdrop
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(iconUrl)}
alt=''
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
draggable={false}
/>
) : hasCustomIcon ? (
// Custom icon without dimensions: fixed size
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(iconUrl)}
alt={type === 'prev' ? 'Previous' : 'Next'}
className='w-10 h-10 object-contain'
draggable={false}
/>
) : (
// Default style: MDI icon with backdrop
<div className='rounded-full bg-black/40 p-3 backdrop-blur-sm'>
<Icon path={defaultIcon || ''} size={1.5} className='text-white' />
</div>
)}
</button>
);
};
// Full-width carousel - two layers:
// 1. Background image layer at z-10 (behind canvas elements)
// 2. Navigation/caption layer at z-30 (above everything for clickability)
const fullWidthBackground = (
<div className='fixed inset-0 z-10 overflow-hidden bg-black pointer-events-none'>
{currentSlide?.imageUrl && (
// 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-cover'
draggable={false}
/>
)}
</div>
);
const fullWidthControls = (
<div
ref={overlayRef}
className='fixed inset-0 z-30 pointer-events-none'
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Navigation buttons */}
{showNavigation && (
<>
{renderNavButton(
'prev',
positions.prevX,
positions.prevY,
element.carouselPrevIconUrl,
mdiChevronLeft,
element.carouselPrevWidth,
element.carouselPrevHeight,
)}
{renderNavButton(
'next',
positions.nextX,
positions.nextY,
element.carouselNextIconUrl,
mdiChevronRight,
element.carouselNextWidth,
element.carouselNextHeight,
)}
</>
)}
{/* Caption */}
{currentSlide?.caption && (
<div
className='absolute bottom-8 left-0 right-0 text-center text-white text-lg px-4'
style={captionFontStyle}
>
{currentSlide.caption}
</div>
)}
</div>
);
// Full-width mode: use portal to render outside transform hierarchy
// Background at z-10, controls at z-30 for clickability
if (isFullWidth) {
// SSR safety: only use portal when mounted in browser
if (!isMounted) {
return null;
}
// In edit mode: render a clickable placeholder so the element can be selected
// Plus the portal for visual display
if (isEditMode) {
return (
<>
{/* Clickable placeholder for element selection */}
<div className={className} style={style}>
<div className='relative w-full h-full min-w-[120px] min-h-[80px] flex items-center justify-center bg-gray-800/50 rounded border-2 border-dashed border-blue-400'>
<span className='text-white text-xs px-2 py-1 bg-blue-500 rounded'>
Full-width Carousel
</span>
</div>
</div>
{/* Portal for full-screen visual */}
{createPortal(fullWidthBackground, document.body)}
{createPortal(fullWidthControls, document.body)}
</>
);
}
// Runtime mode: render portal only
return (
<>
{createPortal(fullWidthBackground, document.body)}
{createPortal(fullWidthControls, document.body)}
</>
);
}
// Normal mode: inline carousel within element dimensions
return (
<div className={className} style={style}>
<div className='relative w-full h-full min-w-[120px] min-h-[80px]'>
{firstSlide?.imageUrl && (
{/* Current slide image */}
{currentSlide?.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(firstSlide.imageUrl)}
alt={firstSlide.caption || 'Carousel slide'}
src={resolve(currentSlide.imageUrl)}
alt={currentSlide.caption || 'Carousel slide'}
className='w-full h-full object-cover rounded'
draggable={false}
/>
)}
{/* Carousel navigation overlay */}
<div className='absolute bottom-2 left-0 right-0 flex justify-center gap-1'>
{slides.map((slide, index) => (
<div
key={slide.id}
className={`w-2 h-2 rounded-full ${
index === 0 ? 'bg-white' : 'bg-white/50'
}`}
/>
))}
</div>
{/* Prev/Next icons if configured */}
{element.carouselPrevIconUrl && (
<div className='absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8'>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolve(element.carouselPrevIconUrl)}
alt='Previous'
className='w-full h-full object-contain'
draggable={false}
/>
</div>
{/* Navigation buttons */}
{showNavigation && (
<>
{/* Prev button */}
<button
type='button'
onClick={handlePrevClick}
className='absolute left-2 top-1/2 -translate-y-1/2 cursor-pointer hover:scale-110 transition-transform'
>
{element.carouselPrevIconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(element.carouselPrevIconUrl)}
alt='Previous'
className='w-8 h-8 object-contain'
draggable={false}
/>
) : (
<div className='rounded-full bg-black/40 p-2 backdrop-blur-sm'>
<Icon path={mdiChevronLeft} size={1} className='text-white' />
</div>
)}
</button>
{/* Next button */}
<button
type='button'
onClick={handleNextClick}
className='absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer hover:scale-110 transition-transform'
>
{element.carouselNextIconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(element.carouselNextIconUrl)}
alt='Next'
className='w-8 h-8 object-contain'
draggable={false}
/>
) : (
<div className='rounded-full bg-black/40 p-2 backdrop-blur-sm'>
<Icon
path={mdiChevronRight}
size={1}
className='text-white'
/>
</div>
)}
</button>
</>
)}
{element.carouselNextIconUrl && (
<div className='absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8'>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolve(element.carouselNextIconUrl)}
alt='Next'
className='w-full h-full object-contain'
draggable={false}
/>
{/* Caption (if present) */}
{currentSlide?.caption && (
<div
className='absolute bottom-4 left-0 right-0 text-center text-white text-sm px-2'
style={captionFontStyle}
>
{currentSlide.caption}
</div>
)}
</div>

View File

@ -230,6 +230,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}, []),
});
// Check if any element has full-width carousel mode enabled
const hasFullWidthCarousel = useMemo(
() => elements.some((el) => el.carouselFullWidth === true),
[elements],
);
// Draggable panels using useDraggable hook
const {
position: constructorControlsPosition,
@ -855,6 +861,40 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
typeof item.carouselNextIconUrl === 'string'
? item.carouselNextIconUrl
: '',
// Carousel button positions
carouselPrevX:
typeof item.carouselPrevX === 'number'
? item.carouselPrevX
: undefined,
carouselPrevY:
typeof item.carouselPrevY === 'number'
? item.carouselPrevY
: undefined,
carouselNextX:
typeof item.carouselNextX === 'number'
? item.carouselNextX
: undefined,
carouselNextY:
typeof item.carouselNextY === 'number'
? item.carouselNextY
: undefined,
// Carousel button dimensions
carouselPrevWidth:
typeof item.carouselPrevWidth === 'string'
? item.carouselPrevWidth
: undefined,
carouselPrevHeight:
typeof item.carouselPrevHeight === 'string'
? item.carouselPrevHeight
: undefined,
carouselNextWidth:
typeof item.carouselNextWidth === 'string'
? item.carouselNextWidth
: undefined,
carouselNextHeight:
typeof item.carouselNextHeight === 'string'
? item.carouselNextHeight
: undefined,
// Gallery Carousel Settings
galleryCarouselPrevIconUrl:
typeof item.galleryCarouselPrevIconUrl === 'string'
@ -1150,7 +1190,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
);
// Handler for gallery carousel button position changes (constructor only)
const handleCarouselButtonPositionChange = useCallback(
const handleGalleryCarouselButtonPositionChange = useCallback(
(button: 'prev' | 'next' | 'back', x: number, y: number) => {
if (!activeGalleryCarousel) return;
@ -1173,6 +1213,23 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
[activeGalleryCarousel, updateSelectedElement],
);
// Handler for carousel element button position changes (constructor only)
const handleCarouselButtonPositionChange = useCallback(
(elementId: string, button: 'prev' | 'next', x: number, y: number) => {
const positionPatch =
button === 'prev'
? { carouselPrevX: x, carouselPrevY: y }
: { carouselNextX: x, carouselNextY: y };
setElements((prev) =>
prev.map((el) =>
el.id === elementId ? { ...el, ...positionPatch } : el,
),
);
},
[setElements],
);
const isElementVisibleOnCanvas = (element: CanvasElement) =>
isElementVisibleAtTime(
canvasElapsedSec,
@ -1303,7 +1360,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
<div
ref={canvasRef}
tabIndex={-1}
className='absolute inset-0 bg-black overflow-clip'
className={`absolute inset-0 z-20 overflow-clip ${hasFullWidthCarousel ? 'bg-transparent' : 'bg-black'}`}
style={canvasBackgroundStyle}
>
<BackdropPortalProvider>
@ -1364,6 +1421,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex)
}
onCarouselButtonPositionChange={(button, x, y) =>
handleCarouselButtonPositionChange(
element.id,
button,
x,
y,
)
}
/>
);
})
@ -1490,7 +1555,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
isEditMode={isConstructorEditMode}
onButtonPositionChange={handleCarouselButtonPositionChange}
onButtonPositionChange={handleGalleryCarouselButtonPositionChange}
/>
)}

View File

@ -164,6 +164,17 @@ export interface CanvasElement extends BaseCanvasElement {
carouselCaptionFontFamily?: string;
carouselPrevIconUrl?: string;
carouselNextIconUrl?: string;
carouselFullWidth?: boolean;
// Carousel button positions (percentage 0-100)
carouselPrevX?: number;
carouselPrevY?: number;
carouselNextX?: number;
carouselNextY?: number;
// Carousel button dimensions (CSS values like '3rem', '48px')
carouselPrevWidth?: string;
carouselPrevHeight?: string;
carouselNextWidth?: string;
carouselNextHeight?: string;
tooltipTitle?: string;
tooltipText?: string;
tooltipTitleFontFamily?: string;