- 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 -
546 lines
17 KiB
TypeScript
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;
|