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;
|
resolveUrl?: (url: string | undefined) => string;
|
||||||
/** Gallery card click handler */
|
/** Gallery card click handler */
|
||||||
onGalleryCardClick?: (cardIndex: number) => void;
|
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> = ({
|
const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||||
@ -40,6 +46,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
onMouseDown,
|
onMouseDown,
|
||||||
resolveUrl,
|
resolveUrl,
|
||||||
onGalleryCardClick,
|
onGalleryCardClick,
|
||||||
|
onCarouselButtonPositionChange,
|
||||||
}) => {
|
}) => {
|
||||||
// Extract effect properties from element
|
// Extract effect properties from element
|
||||||
const effectProperties: Partial<ElementEffectProperties> = {
|
const effectProperties: Partial<ElementEffectProperties> = {
|
||||||
@ -122,6 +129,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
onGalleryCardClick={onGalleryCardClick}
|
onGalleryCardClick={onGalleryCardClick}
|
||||||
|
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -566,6 +566,21 @@ export function ElementEditorPanel({
|
|||||||
carouselCaptionFontFamily={
|
carouselCaptionFontFamily={
|
||||||
selectedElement.carouselCaptionFontFamily || ''
|
selectedElement.carouselCaptionFontFamily || ''
|
||||||
}
|
}
|
||||||
|
carouselFullWidth={
|
||||||
|
selectedElement.carouselFullWidth || false
|
||||||
|
}
|
||||||
|
carouselPrevWidth={
|
||||||
|
selectedElement.carouselPrevWidth || ''
|
||||||
|
}
|
||||||
|
carouselPrevHeight={
|
||||||
|
selectedElement.carouselPrevHeight || ''
|
||||||
|
}
|
||||||
|
carouselNextWidth={
|
||||||
|
selectedElement.carouselNextWidth || ''
|
||||||
|
}
|
||||||
|
carouselNextHeight={
|
||||||
|
selectedElement.carouselNextHeight || ''
|
||||||
|
}
|
||||||
iconAssetOptions={iconAssetOptions}
|
iconAssetOptions={iconAssetOptions}
|
||||||
imageAssetOptions={imageAssetOptions}
|
imageAssetOptions={imageAssetOptions}
|
||||||
onUpdateElement={onUpdateElement}
|
onUpdateElement={onUpdateElement}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* CarouselSettingsSectionCompact
|
* CarouselSettingsSectionCompact
|
||||||
*
|
*
|
||||||
* Compact carousel element settings for constructor sidebar.
|
* Compact carousel element settings for constructor sidebar.
|
||||||
* Navigation icons and slide management.
|
* Navigation icons, dimensions, and slide management.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@ -15,12 +15,22 @@ interface CarouselSettingsSectionCompactProps {
|
|||||||
carouselPrevIconUrl: string;
|
carouselPrevIconUrl: string;
|
||||||
carouselNextIconUrl: string;
|
carouselNextIconUrl: string;
|
||||||
carouselCaptionFontFamily: string;
|
carouselCaptionFontFamily: string;
|
||||||
|
carouselFullWidth: boolean;
|
||||||
|
carouselPrevWidth: string;
|
||||||
|
carouselPrevHeight: string;
|
||||||
|
carouselNextWidth: string;
|
||||||
|
carouselNextHeight: string;
|
||||||
iconAssetOptions: AssetOption[];
|
iconAssetOptions: AssetOption[];
|
||||||
imageAssetOptions: AssetOption[];
|
imageAssetOptions: AssetOption[];
|
||||||
onUpdateElement: (patch: {
|
onUpdateElement: (patch: {
|
||||||
carouselPrevIconUrl?: string;
|
carouselPrevIconUrl?: string;
|
||||||
carouselNextIconUrl?: string;
|
carouselNextIconUrl?: string;
|
||||||
carouselCaptionFontFamily?: string;
|
carouselCaptionFontFamily?: string;
|
||||||
|
carouselFullWidth?: boolean;
|
||||||
|
carouselPrevWidth?: string;
|
||||||
|
carouselPrevHeight?: string;
|
||||||
|
carouselNextWidth?: string;
|
||||||
|
carouselNextHeight?: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
onAddSlide: () => void;
|
onAddSlide: () => void;
|
||||||
onUpdateSlide: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
onUpdateSlide: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
||||||
@ -34,6 +44,11 @@ const CarouselSettingsSectionCompact: React.FC<
|
|||||||
carouselPrevIconUrl,
|
carouselPrevIconUrl,
|
||||||
carouselNextIconUrl,
|
carouselNextIconUrl,
|
||||||
carouselCaptionFontFamily,
|
carouselCaptionFontFamily,
|
||||||
|
carouselFullWidth,
|
||||||
|
carouselPrevWidth,
|
||||||
|
carouselPrevHeight,
|
||||||
|
carouselNextWidth,
|
||||||
|
carouselNextHeight,
|
||||||
iconAssetOptions,
|
iconAssetOptions,
|
||||||
imageAssetOptions,
|
imageAssetOptions,
|
||||||
onUpdateElement,
|
onUpdateElement,
|
||||||
@ -43,6 +58,25 @@ const CarouselSettingsSectionCompact: React.FC<
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className='space-y-2'>
|
<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'>
|
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
||||||
<p className='text-[11px] font-semibold text-gray-700'>
|
<p className='text-[11px] font-semibold text-gray-700'>
|
||||||
Navigation icons
|
Navigation icons
|
||||||
@ -67,6 +101,34 @@ const CarouselSettingsSectionCompact: React.FC<
|
|||||||
))}
|
))}
|
||||||
</select>
|
</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
|
<select
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={carouselNextIconUrl}
|
value={carouselNextIconUrl}
|
||||||
@ -86,6 +148,34 @@ const CarouselSettingsSectionCompact: React.FC<
|
|||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
<div>
|
||||||
<label className='text-[10px] text-gray-600'>Caption font:</label>
|
<label className='text-[10px] text-gray-600'>Caption font:</label>
|
||||||
<select
|
<select
|
||||||
@ -103,6 +193,13 @@ const CarouselSettingsSectionCompact: React.FC<
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
|
|||||||
@ -45,6 +45,12 @@ export interface UiElementRendererProps {
|
|||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
// Gallery carousel callback
|
// Gallery carousel callback
|
||||||
onGalleryCardClick?: (cardIndex: number) => void;
|
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,
|
isEditMode = false,
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
onGalleryCardClick,
|
onGalleryCardClick,
|
||||||
|
onCarouselButtonPositionChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { className, style } = useElementWrapperStyle({
|
const { className, style } = useElementWrapperStyle({
|
||||||
element,
|
element,
|
||||||
@ -85,7 +92,13 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
|||||||
return <DescriptionElement {...commonProps} />;
|
return <DescriptionElement {...commonProps} />;
|
||||||
}
|
}
|
||||||
if (isCarouselElementType(element.type)) {
|
if (isCarouselElementType(element.type)) {
|
||||||
return <CarouselElement {...commonProps} />;
|
return (
|
||||||
|
<CarouselElement
|
||||||
|
{...commonProps}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
onButtonPositionChange={onCarouselButtonPositionChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (isVideoPlayerElementType(element.type)) {
|
if (isVideoPlayerElementType(element.type)) {
|
||||||
return <VideoPlayerElement {...commonProps} />;
|
return <VideoPlayerElement {...commonProps} />;
|
||||||
|
|||||||
@ -2,11 +2,29 @@
|
|||||||
* CarouselElement Component
|
* CarouselElement Component
|
||||||
*
|
*
|
||||||
* Carousel element - slideshow of images with navigation.
|
* 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 type { CSSProperties } from 'react';
|
||||||
|
import Icon from '@mdi/react';
|
||||||
|
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||||
import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
|
import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
|
||||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||||
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
|
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
|
||||||
@ -16,17 +34,93 @@ interface CarouselElementProps {
|
|||||||
resolveUrl?: (url: string | undefined) => string;
|
resolveUrl?: (url: string | undefined) => string;
|
||||||
className: string;
|
className: string;
|
||||||
style: CSSProperties;
|
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> = ({
|
const CarouselElement: React.FC<CarouselElementProps> = ({
|
||||||
element,
|
element,
|
||||||
resolveUrl,
|
resolveUrl,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
isEditMode = false,
|
||||||
|
onButtonPositionChange,
|
||||||
}) => {
|
}) => {
|
||||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||||
const slides: CarouselSlide[] = element.carouselSlides || [];
|
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)
|
// Resolve font key to full CSS style (including fontStretch for condensed variants)
|
||||||
const captionFontStyle = useMemo(() => {
|
const captionFontStyle = useMemo(() => {
|
||||||
@ -36,50 +130,362 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
|||||||
return font ? getFontStyle(font) : { fontFamily: fontKey };
|
return font ? getFontStyle(font) : { fontFamily: fontKey };
|
||||||
}, [element.carouselCaptionFontFamily]);
|
}, [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 (
|
return (
|
||||||
<div className={className} style={style}>
|
<div className={className} style={style}>
|
||||||
<div className='relative w-full h-full min-w-[120px] min-h-[80px]'>
|
<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
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={resolve(firstSlide.imageUrl)}
|
src={resolve(currentSlide.imageUrl)}
|
||||||
alt={firstSlide.caption || 'Carousel slide'}
|
alt={currentSlide.caption || 'Carousel slide'}
|
||||||
className='w-full h-full object-cover rounded'
|
className='w-full h-full object-cover rounded'
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Carousel navigation overlay */}
|
|
||||||
<div className='absolute bottom-2 left-0 right-0 flex justify-center gap-1'>
|
{/* Navigation buttons */}
|
||||||
{slides.map((slide, index) => (
|
{showNavigation && (
|
||||||
<div
|
<>
|
||||||
key={slide.id}
|
{/* Prev button */}
|
||||||
className={`w-2 h-2 rounded-full ${
|
<button
|
||||||
index === 0 ? 'bg-white' : 'bg-white/50'
|
type='button'
|
||||||
}`}
|
onClick={handlePrevClick}
|
||||||
/>
|
className='absolute left-2 top-1/2 -translate-y-1/2 cursor-pointer hover:scale-110 transition-transform'
|
||||||
))}
|
>
|
||||||
</div>
|
{element.carouselPrevIconUrl ? (
|
||||||
{/* Prev/Next icons if configured */}
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
{element.carouselPrevIconUrl && (
|
<img
|
||||||
<div className='absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8'>
|
src={resolve(element.carouselPrevIconUrl)}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
alt='Previous'
|
||||||
<img
|
className='w-8 h-8 object-contain'
|
||||||
src={resolve(element.carouselPrevIconUrl)}
|
draggable={false}
|
||||||
alt='Previous'
|
/>
|
||||||
className='w-full h-full 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>
|
</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'>
|
{/* Caption (if present) */}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{currentSlide?.caption && (
|
||||||
<img
|
<div
|
||||||
src={resolve(element.carouselNextIconUrl)}
|
className='absolute bottom-4 left-0 right-0 text-center text-white text-sm px-2'
|
||||||
alt='Next'
|
style={captionFontStyle}
|
||||||
className='w-full h-full object-contain'
|
>
|
||||||
draggable={false}
|
{currentSlide.caption}
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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
|
// Draggable panels using useDraggable hook
|
||||||
const {
|
const {
|
||||||
position: constructorControlsPosition,
|
position: constructorControlsPosition,
|
||||||
@ -855,6 +861,40 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
typeof item.carouselNextIconUrl === 'string'
|
typeof item.carouselNextIconUrl === 'string'
|
||||||
? item.carouselNextIconUrl
|
? 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
|
// Gallery Carousel Settings
|
||||||
galleryCarouselPrevIconUrl:
|
galleryCarouselPrevIconUrl:
|
||||||
typeof item.galleryCarouselPrevIconUrl === 'string'
|
typeof item.galleryCarouselPrevIconUrl === 'string'
|
||||||
@ -1150,7 +1190,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Handler for gallery carousel button position changes (constructor only)
|
// Handler for gallery carousel button position changes (constructor only)
|
||||||
const handleCarouselButtonPositionChange = useCallback(
|
const handleGalleryCarouselButtonPositionChange = useCallback(
|
||||||
(button: 'prev' | 'next' | 'back', x: number, y: number) => {
|
(button: 'prev' | 'next' | 'back', x: number, y: number) => {
|
||||||
if (!activeGalleryCarousel) return;
|
if (!activeGalleryCarousel) return;
|
||||||
|
|
||||||
@ -1173,6 +1213,23 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
[activeGalleryCarousel, updateSelectedElement],
|
[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) =>
|
const isElementVisibleOnCanvas = (element: CanvasElement) =>
|
||||||
isElementVisibleAtTime(
|
isElementVisibleAtTime(
|
||||||
canvasElapsedSec,
|
canvasElapsedSec,
|
||||||
@ -1303,7 +1360,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
<div
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
tabIndex={-1}
|
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}
|
style={canvasBackgroundStyle}
|
||||||
>
|
>
|
||||||
<BackdropPortalProvider>
|
<BackdropPortalProvider>
|
||||||
@ -1364,6 +1421,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
onGalleryCardClick={(cardIndex) =>
|
onGalleryCardClick={(cardIndex) =>
|
||||||
handleGalleryCardClick(element, 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}
|
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
|
||||||
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
|
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
|
||||||
isEditMode={isConstructorEditMode}
|
isEditMode={isConstructorEditMode}
|
||||||
onButtonPositionChange={handleCarouselButtonPositionChange}
|
onButtonPositionChange={handleGalleryCarouselButtonPositionChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -164,6 +164,17 @@ export interface CanvasElement extends BaseCanvasElement {
|
|||||||
carouselCaptionFontFamily?: string;
|
carouselCaptionFontFamily?: string;
|
||||||
carouselPrevIconUrl?: string;
|
carouselPrevIconUrl?: string;
|
||||||
carouselNextIconUrl?: 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;
|
tooltipTitle?: string;
|
||||||
tooltipText?: string;
|
tooltipText?: string;
|
||||||
tooltipTitleFontFamily?: string;
|
tooltipTitleFontFamily?: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user