39948-vm/frontend/src/components/UiElements/GalleryCarouselOverlay.tsx
Dmitri 6413c7bdf0 implemented:
- three destinations for info panel thumbnails : the panel image preview, new page, external page (URL). Two destinations for other elements (other page, external URL)
- toggle for info panel media opening (fullscreen or in the panel)
- ability to replace background with info panel media (image, video, 360 panorama)
- ability to make 360 panorama as page background
- global mute button
-
2026-06-15 07:50:45 +02:00

546 lines
17 KiB
TypeScript

/**
* 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<GalleryCard | GalleryCarouselMediaItem>;
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<GalleryCarouselOverlayProps> = ({
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<HTMLDivElement>(null);
const canvasContainerRef = useRef<HTMLDivElement>(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 (
<button
type='button'
className={`absolute flex items-center justify-center transition-transform ${
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%)',
// Apply dimensions when set (vw for width, vh for height - matches regular elements)
...(widthValue && { width: widthValue }),
...(heightValue && { height: heightValue }),
}}
onClick={(e) => {
e.stopPropagation();
if (isEditMode) return;
if (type === 'prev') goToPrev();
else if (type === 'next') goToNext();
else onClose();
}}
onMouseDown={(e) => handleButtonDragStart(type, e)}
>
{useNavigationStyle ? (
// Navigation-style: icon fills full button, no backdrop, no label
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(iconUrl)}
alt=''
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
draggable={false}
/>
) : (
// Default style: MDI icon with backdrop
<div
className={`flex items-center gap-2 ${
isNavButton
? 'rounded-full bg-black/40 p-3 backdrop-blur-sm'
: 'rounded-lg bg-black/40 px-4 py-2 backdrop-blur-sm'
}`}
>
{defaultIcon && (
<Icon
path={defaultIcon}
size={isNavButton ? 1.5 : 1}
className='text-white'
/>
)}
{label && (
<span className='text-sm font-medium text-white'>{label}</span>
)}
</div>
)}
</button>
);
};
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 (
<div
ref={overlayRef}
className='fixed inset-0 z-[120] overflow-hidden bg-black'
onClick={(e) => {
// 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 */}
<div
ref={canvasContainerRef}
className='overflow-hidden'
style={letterboxStyles || { position: 'absolute', inset: 0 }}
>
{/* Fullscreen media */}
{mediaType === 'image' && imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageUrl}
alt={mediaTitle}
className='absolute inset-0 h-full w-full object-contain'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
draggable={false}
/>
)}
{mediaType === 'video' && videoUrl && (
<video
key={videoUrl}
src={videoUrl}
className='absolute inset-0 h-full w-full object-contain'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
controls
autoPlay
muted={isMuted}
playsInline
/>
)}
{mediaType === '360' && embedUrl && isValidEmbedUrl(embedUrl) && (
<iframe
key={embedSrc}
src={embedSrc}
title={mediaTitle || '360 view'}
className='absolute inset-0 h-full w-full border-0'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking'
/>
)}
{mediaType === '360' && embedUrl && !isValidEmbedUrl(embedUrl) && (
<div className='absolute inset-0 flex items-center justify-center text-white/70'>
Embed domain is not allowed
</div>
)}
{/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */}
{slideTransition.type === 'fade' && (
<div
className='absolute inset-0 pointer-events-none'
style={{
...overlayTransitionStyle,
backgroundColor: overlayColor,
opacity: overlayOpacity,
}}
/>
)}
{/* Prev button */}
{renderNavButton(
'prev',
positions.prevX,
positions.prevY,
prevIconUrl,
mdiChevronLeft,
undefined,
prevWidth,
prevHeight,
)}
{/* Next button */}
{renderNavButton(
'next',
positions.nextX,
positions.nextY,
nextIconUrl,
mdiChevronRight,
undefined,
nextWidth,
nextHeight,
)}
{/* Back button */}
{renderNavButton(
'back',
positions.backX,
positions.backY,
backIconUrl,
mdiArrowLeft,
backLabel,
backWidth,
backHeight,
)}
</div>
</div>
);
};
export default GalleryCarouselOverlay;