improved carousel UI element
This commit is contained in:
parent
fec8864e07
commit
fd57a3bf10
File diff suppressed because one or more lines are too long
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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} />;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user