/** * GalleryCarouselOverlay Component * * Fullscreen carousel overlay for gallery elements. * Shows images in a slideshow with navigation buttons. * In constructor mode, buttons are draggable for positioning. */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import Icon from '@mdi/react'; import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js'; import type { GalleryCard, GalleryCarouselMediaItem, CanvasElement, } from '../../types/constructor'; import type { ResolvedTransitionSettings } from '../../types/transition'; import { resolveAssetPlaybackUrl } from '../../lib/assetUrl'; import { resolveSlideTransition, extractGallerySlideOverride, } from '../../lib/resolveSlideTransition'; import { useSlideTransition } from '../../hooks/useSlideTransition'; import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute'; import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl'; interface GalleryCarouselOverlayProps { cards: Array; initialIndex: number; onClose: () => void; resolveUrl?: (url: string | undefined) => string; // Button icons (MDI fallback if not set) prevIconUrl?: string; nextIconUrl?: string; backIconUrl?: string; backLabel?: string; // Button positions (percentage-based, like canvas elements) prevX?: number; prevY?: number; nextX?: number; nextY?: number; backX?: number; backY?: number; // Button dimensions (numeric values use vw for width, vh for height - same as regular elements) // When set with custom icon, button renders like NavigationElement (icon fills full button) prevWidth?: string; prevHeight?: string; nextWidth?: string; nextHeight?: string; backWidth?: string; backHeight?: string; // Constructor mode: buttons draggable isEditMode?: boolean; onButtonPositionChange?: ( button: 'prev' | 'next' | 'back', x: number, y: number, ) => void; // Letterbox styles for constraining overlay to canvas bounds letterboxStyles?: React.CSSProperties; // Page transition settings (for slide transition cascade) pageTransitionSettings?: ResolvedTransitionSettings; // Gallery element (for extracting slide transition override) galleryElement?: CanvasElement; } const getMediaType = ( card: GalleryCard | GalleryCarouselMediaItem | undefined, ): 'image' | 'video' | '360' => { if (!card) return 'image'; if ('mediaType' in card && card.mediaType) return card.mediaType; if ('videoUrl' in card && card.videoUrl) return 'video'; if ('embedUrl' in card && card.embedUrl) return '360'; return 'image'; }; const getMediaTitle = ( card: GalleryCard | GalleryCarouselMediaItem | undefined, ): string => { if (!card) return ''; return ( ('title' in card && card.title) || ('caption' in card && card.caption) || ('description' in card && card.description) || '' ); }; const getVideoUrl = ( card: GalleryCard | GalleryCarouselMediaItem | undefined, ): string => (card && 'videoUrl' in card && card.videoUrl ? card.videoUrl : ''); const getEmbedUrl = ( card: GalleryCard | GalleryCarouselMediaItem | undefined, ): string => (card && 'embedUrl' in card && card.embedUrl ? card.embedUrl : ''); const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); const GalleryCarouselOverlay: React.FC = ({ cards, initialIndex, onClose, resolveUrl, prevIconUrl, nextIconUrl, backIconUrl, backLabel = 'BACK', prevX = 5, prevY = 50, nextX = 95, nextY = 50, backX = 5, backY = 90, prevWidth, prevHeight, nextWidth, nextHeight, backWidth, backHeight, isEditMode = false, onButtonPositionChange, letterboxStyles, pageTransitionSettings, galleryElement, }) => { const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const { isMuted } = useGlobalAudioMute(); // Resolve slide transition with cascade const slideTransition = resolveSlideTransition( pageTransitionSettings, extractGallerySlideOverride(galleryElement), ); // Use hook for animation state const { displayIndex, overlayOpacity, overlayColor, goToIndex, setInitialIndex, slideTransitionStyle, overlayTransitionStyle, slideOpacity, } = useSlideTransition(slideTransition); // Set initial index on mount useEffect(() => { setInitialIndex(initialIndex); }, [initialIndex, setInitialIndex]); const [draggingButton, setDraggingButton] = useState< 'prev' | 'next' | 'back' | null >(null); // Clamp positions to canvas bounds (0-100%) const [positions, setPositions] = useState({ prevX: clamp(prevX, 0, 100), prevY: clamp(prevY, 0, 100), nextX: clamp(nextX, 0, 100), nextY: clamp(nextY, 0, 100), backX: clamp(backX, 0, 100), backY: clamp(backY, 0, 100), }); const overlayRef = useRef(null); const canvasContainerRef = useRef(null); const touchStartRef = useRef<{ x: number; y: number } | null>(null); const positionsRef = useRef(positions); positionsRef.current = positions; // Update positions when props change (clamped to canvas bounds) useEffect(() => { setPositions({ prevX: clamp(prevX, 0, 100), prevY: clamp(prevY, 0, 100), nextX: clamp(nextX, 0, 100), nextY: clamp(nextY, 0, 100), backX: clamp(backX, 0, 100), backY: clamp(backY, 0, 100), }); }, [prevX, prevY, nextX, nextY, backX, backY]); // Navigation handlers const goToPrev = useCallback(() => { if (cards.length === 0) return; const newIndex = (displayIndex - 1 + cards.length) % cards.length; goToIndex(newIndex); }, [cards.length, displayIndex, goToIndex]); const goToNext = useCallback(() => { if (cards.length === 0) return; const newIndex = (displayIndex + 1) % cards.length; goToIndex(newIndex); }, [cards.length, displayIndex, goToIndex]); // Keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } else if (e.key === 'ArrowLeft') { goToPrev(); } else if (e.key === 'ArrowRight') { goToNext(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose, goToPrev, goToNext]); // Touch swipe handling const handleTouchStart = (e: React.TouchEvent) => { if (isEditMode) return; touchStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY, }; }; const handleTouchEnd = (e: React.TouchEvent) => { if (isEditMode || !touchStartRef.current) return; const touchEnd = { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY, }; const deltaX = touchEnd.x - touchStartRef.current.x; const threshold = 50; if (Math.abs(deltaX) > threshold) { if (deltaX > 0) { goToPrev(); } else { goToNext(); } } touchStartRef.current = null; }; // Draggable button handling (constructor mode only) const handleButtonDragStart = ( button: 'prev' | 'next' | 'back', e: React.MouseEvent, ) => { if (!isEditMode) return; e.preventDefault(); e.stopPropagation(); setDraggingButton(button); }; useEffect(() => { if (!isEditMode || !draggingButton) return; const handleMove = (e: MouseEvent) => { // Calculate position relative to canvas container (strict - no viewport fallback) const container = canvasContainerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * 100; const y = ((e.clientY - rect.top) / rect.height) * 100; // Clamp to canvas bounds (0-100%) const clampedX = clamp(x, 0, 100); const clampedY = clamp(y, 0, 100); setPositions((prev) => { // Prev and next buttons share the same Y coordinate if (draggingButton === 'prev' || draggingButton === 'next') { return { ...prev, [`${draggingButton}X`]: clampedX, prevY: clampedY, nextY: clampedY, }; } // Back button has independent position return { ...prev, [`${draggingButton}X`]: clampedX, [`${draggingButton}Y`]: clampedY, }; }); }; const handleUp = () => { if (onButtonPositionChange && draggingButton) { const currentPositions = positionsRef.current; const posKey = `${draggingButton}X` as keyof typeof positions; const posKeyY = `${draggingButton}Y` as keyof typeof positions; onButtonPositionChange( draggingButton, currentPositions[posKey], currentPositions[posKeyY], ); // For prev/next, also update the other button's Y position if (draggingButton === 'prev') { onButtonPositionChange( 'next', currentPositions.nextX, currentPositions.prevY, ); } else if (draggingButton === 'next') { onButtonPositionChange( 'prev', currentPositions.prevX, currentPositions.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) // Matches the unit system used by regular elements in elementStyles.ts 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 // When custom icon is set, render like NavigationElement (icon fills full button, no backdrop) // Otherwise, use MDI fallback with backdrop styling const renderNavButton = ( type: 'prev' | 'next' | 'back', x: number, y: number, iconUrl?: string, defaultIcon?: string, label?: string, buttonWidth?: string, buttonHeight?: string, ) => { const isDragging = draggingButton === type; const isNavButton = type === 'prev' || type === 'next'; const hasCustomIcon = iconUrl && iconUrl.trim() !== ''; const widthValue = toViewportUnit(buttonWidth, 'vw'); const heightValue = toViewportUnit(buttonHeight, 'vh'); // Navigation-style rendering: custom icon fills full button (like NavigationElement) // When custom icon is set, always use navigation style (icon only, no backdrop) const useNavigationStyle = hasCustomIcon; return ( ); }; const currentCard = cards[displayIndex]; const mediaType = getMediaType(currentCard); const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : ''; const rawVideoUrl = getVideoUrl(currentCard); const videoUrl = rawVideoUrl ? resolve(rawVideoUrl) : ''; const embedUrl = getEmbedUrl(currentCard); const embedSrc = embedUrl && isValidEmbedUrl(embedUrl) ? buildChromeFreeEmbedUrl(embedUrl) : ''; const mediaTitle = getMediaTitle(currentCard); return (
{ // Only close if clicking the background, not buttons if (e.target === overlayRef.current && !isEditMode) { onClose(); } }} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} > {/* Inner container constrained to canvas bounds - no conflicting CSS classes */}
{/* Fullscreen media */} {mediaType === 'image' && imageUrl && ( // eslint-disable-next-line @next/next/no-img-element {mediaTitle} )} {mediaType === 'video' && videoUrl && (