/** * InfoPanelOverlay Component * * Overlay panel that displays header, title, info spans, text, and image cards. * Section-based structure similar to Gallery for consistent styling. * Positioned absolutely within the canvas (not fullscreen). * Supports keyboard navigation, click outside to close, and mobile touch. */ import React, { useState, useEffect, useCallback, useRef, useMemo, } from 'react'; import type { CanvasElement, InfoPanelImage, InfoPanelSectionInstance, } from '../../types/constructor'; import { getInfoPanelSections } from '../../types/constructor'; import { resolveAssetPlaybackUrl } from '../../lib/assetUrl'; import { buildInfoPanelHeaderStyle, buildInfoPanelTitleStyle, buildInfoPanelTextStyle, buildInfoPanelSpanStyle, buildInfoPanelSpanGridStyleWithSection, buildInfoPanelCardStyle, buildInfoPanelCardTitleStyle, buildInfoPanelCardGridStyleWithSection, buildInfoPanelWrapperStyle, buildImagesPreviewStyle, } from '../../lib/infoPanelSectionStyles'; interface InfoPanelOverlayProps { element: CanvasElement; onClose: () => void; resolveUrl?: (url: string | undefined) => string; letterboxStyles?: React.CSSProperties; /** CSS custom properties including --cu for canvas units */ cssVars?: React.CSSProperties; /** Callback when an image/360 is clicked. Pass null to close the detail panel. */ onImageClick: (image: InfoPanelImage | null) => void; /** Callback when an image is selected in the images section preview */ onSelectImage?: (imageId: string) => void; isEditMode?: boolean; /** Callback when panel position changes (edit mode only) */ onPanelPositionChange?: (xPercent: number, yPercent: number) => void; /** Currently active 360° item ID (for toggle state sync with parent) */ active360ItemId?: string | null; } const InfoPanelOverlay: React.FC = ({ element, onClose, resolveUrl, letterboxStyles, cssVars, onImageClick, onSelectImage, isEditMode = false, onPanelPositionChange, active360ItemId, }) => { const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const overlayRef = useRef(null); const panelRef = useRef(null); const containerRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const [isDragging, setIsDragging] = useState(false); const dragStartRef = useRef<{ x: number; y: number; panelX: number; panelY: number; } | null>(null); // Track selected image per section (local UI state) const [selectedImagePerSection, setSelectedImagePerSection] = useState< Record >({}); // Section styles computed from element settings const headerStyle = useMemo( () => buildInfoPanelHeaderStyle(element), [element], ); const titleStyle = useMemo( () => buildInfoPanelTitleStyle(element), [element], ); const textStyle = useMemo(() => buildInfoPanelTextStyle(element), [element]); const spanStyle = useMemo(() => buildInfoPanelSpanStyle(element), [element]); const cardStyle = useMemo(() => buildInfoPanelCardStyle(element), [element]); const cardTitleStyle = useMemo( () => buildInfoPanelCardTitleStyle(element), [element], ); const wrapperStyle = useMemo( () => buildInfoPanelWrapperStyle(element), [element], ); const imagesPreviewStyle = useMemo( () => buildImagesPreviewStyle(element), [element], ); // Get section instances const sections = useMemo(() => getInfoPanelSections(element), [element]); // Fade in animation useEffect(() => { requestAnimationFrame(() => setIsVisible(true)); }, []); // Keyboard navigation (ESC to close) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); // Focus trap useEffect(() => { const panel = panelRef.current; if (!panel) return; // Focus the panel on mount panel.focus(); }, []); // Drag handling for edit mode const handleDragStart = useCallback( (e: React.MouseEvent) => { if (!isEditMode || !onPanelPositionChange) return; e.preventDefault(); e.stopPropagation(); const panelX = element.panelXPercent ?? 50; const panelY = element.panelYPercent ?? 50; dragStartRef.current = { x: e.clientX, y: e.clientY, panelX, panelY, }; setIsDragging(true); }, [ isEditMode, onPanelPositionChange, element.panelXPercent, element.panelYPercent, ], ); useEffect(() => { if (!isEditMode || !isDragging || !onPanelPositionChange) return; const handleMouseMove = (e: MouseEvent) => { if (!dragStartRef.current || !containerRef.current) return; const container = containerRef.current; const rect = container.getBoundingClientRect(); const deltaX = e.clientX - dragStartRef.current.x; const deltaY = e.clientY - dragStartRef.current.y; // Convert pixel delta to percentage const deltaXPercent = (deltaX / rect.width) * 100; const deltaYPercent = (deltaY / rect.height) * 100; const newX = Math.max( 0, Math.min(100, dragStartRef.current.panelX + deltaXPercent), ); const newY = Math.max( 0, Math.min(100, dragStartRef.current.panelY + deltaYPercent), ); onPanelPositionChange(newX, newY); }; const handleMouseUp = () => { dragStartRef.current = null; setIsDragging(false); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [isEditMode, isDragging, onPanelPositionChange]); // Handle backdrop click (close when clicking outside panel) const handleBackdropClick = useCallback( (e: React.MouseEvent) => { if (e.target === overlayRef.current && !isEditMode) { onClose(); } }, [onClose, isEditMode], ); // Handle touch on backdrop const handleBackdropTouch = useCallback( (e: React.TouchEvent) => { if (e.target === overlayRef.current && !isEditMode) { onClose(); } }, [onClose, isEditMode], ); // Extract panel styling from element const panelXPercent = element.panelXPercent ?? 50; const panelYPercent = element.panelYPercent ?? 50; const panelWidth = element.panelWidth ?? '400'; const panelHeight = element.panelHeight ?? 'auto'; const panelBackgroundColor = element.panelBackgroundColor ?? 'rgba(0, 0, 0, 0.85)'; const panelBorderRadius = element.panelBorderRadius ?? '12'; const panelBorderWidth = element.panelBorderWidth || '0'; const panelBorderColor = element.panelBorderColor || '#ffffff'; const panelBorderStyle = element.panelBorderStyle || 'solid'; const panelPadding = element.panelPadding ?? '20'; const panelBackdropBlur = element.panelBackdropBlur ?? '10px'; const panelTitle = element.panelTitle ?? 'Information'; const panelText = element.panelText ?? ''; const panelOverlayColor = element.panelOverlayColor ?? 'rgba(0, 0, 0, 0.3)'; // Check if overlay should be visible const isOverlayVisible = panelOverlayColor !== 'transparent' && panelOverlayColor !== 'none'; // Convert numeric values to canvas units for responsive scaling const toCU = (value: string): string => { const trimmed = value.trim(); // Zero doesn't need a unit if (trimmed === '0') return '0'; // Already uses canvas units - return as-is if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) return trimmed; // CSS functions (calc, var, min, max, etc.) - return as-is if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed; // Already has a unit suffix - return as-is if (/[a-z%]+$/i.test(trimmed)) return trimmed; // Parse numeric value and convert to canvas units const num = parseFloat(trimmed); if (!Number.isFinite(num)) return trimmed; return `calc(${num} * var(--cu, 1px))`; }; // Panel style with responsive constraints const panelStyle: React.CSSProperties = { position: 'absolute', left: `${panelXPercent}%`, top: `${panelYPercent}%`, transform: 'translate(-50%, -50%)', width: panelWidth === 'auto' ? 'auto' : `min(${toCU(panelWidth)}, calc(100vw - 32px))`, maxHeight: panelHeight === 'auto' ? 'calc(100dvh - 64px)' : `min(${toCU(panelHeight)}, calc(100dvh - 64px))`, backgroundColor: panelBackgroundColor, borderRadius: toCU(panelBorderRadius), border: panelBorderWidth !== '0' && panelBorderWidth ? `${toCU(panelBorderWidth)} ${panelBorderStyle} ${panelBorderColor}` : 'none', padding: toCU(panelPadding), WebkitBackdropFilter: `blur(${panelBackdropBlur})`, backdropFilter: `blur(${panelBackdropBlur})`, overflowY: 'auto', opacity: isVisible ? 1 : 0, transition: 'opacity 200ms ease-out', // Safe area for notched devices paddingTop: `max(${toCU(panelPadding)}, env(safe-area-inset-top))`, paddingRight: `max(${toCU(panelPadding)}, env(safe-area-inset-right))`, paddingBottom: `max(${toCU(panelPadding)}, env(safe-area-inset-bottom))`, paddingLeft: `max(${toCU(panelPadding)}, env(safe-area-inset-left))`, }; return ( <> {/* Backdrop overlay - separate from panel for correct z-index stacking */}
{/* Inner container constrained to canvas bounds */}
{/* Info Panel */}
e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()} > {/* Drag handle header (edit mode only) */} {isEditMode && onPanelPositionChange && (
{/* Drag indicator dots */}
)} {/* Close button */} {/* Content wrapper - disable pointer events in edit mode for dragging */}
{/* Render sections in dynamic order using section instances */} {sections.map((section) => { switch (section.type) { case 'header': { // Header section: use section-level data or fall back to element-level const headerImageUrl = section.headerImageUrl ?? element.infoPanelHeaderImageUrl; const headerText = section.headerText ?? element.infoPanelHeaderText; // Image takes priority, otherwise render text if (headerImageUrl) { return (
{/* eslint-disable-next-line @next/next/no-img-element */}
); } if (headerText) { return (
{headerText}
); } return null; } case 'title': { // Use section-level title or fall back to element-level const titleContent = section.title ?? panelTitle; if (!titleContent) return null; return (

{titleContent}

); } case 'text': { // Use section-level text or fall back to element-level const textContent = section.text ?? panelText; if (!textContent) return null; return (

{textContent}

); } case 'spans': { // Use section-level spans const sectionSpans = section.spans || []; if (sectionSpans.length === 0) return null; // Build grid style with per-section settings const spanGridStyle = buildInfoPanelSpanGridStyleWithSection( element, section, ); return (
{sectionSpans.map((span) => (
{span.iconUrl ? ( /* eslint-disable-next-line @next/next/no-img-element */ {span.text ) : ( span.text )}
))}
); } case 'cards': { // Use section-level images const sectionImages = section.images || []; if (sectionImages.length === 0) return null; // Build grid style with per-section settings const cardGridStyle = buildInfoPanelCardGridStyleWithSection( element, section, ); return (
{sectionImages.map((image) => ( ))}
); } case 'images': { // Use section-level images const sectionImages = section.images || []; // Filter to get only regular images (not 360°) const imageItems = sectionImages.filter( (img) => img.itemType !== '360', ); // Get 360° items for trigger buttons const triggerItems = sectionImages.filter( (img) => img.itemType === '360', ); // Nothing to show if no items if (imageItems.length === 0 && triggerItems.length === 0) return null; // Use per-section selected state, fallback to first image const selectedId = selectedImagePerSection[section.id] || imageItems[0]?.id; const selectedImage = imageItems.find( (img) => img.id === selectedId, ); // Handler to update selected image for this section const handleSelectImage = (imageId: string) => { setSelectedImagePerSection((prev) => ({ ...prev, [section.id]: imageId, })); // Also call the optional external handler onSelectImage?.(imageId); }; // Build grid style with per-section settings const gridStyle = buildInfoPanelCardGridStyleWithSection( element, section, ); // Thumbnail style for grid items const thumbnailStyle: React.CSSProperties = { ...cardStyle, display: 'block', width: '100%', cursor: 'pointer', border: 'none', padding: 0, overflow: 'hidden', }; return (
{/* Large Preview - always visible */}
{selectedImage?.imageUrl ? ( // eslint-disable-next-line @next/next/no-img-element {selectedImage.caption ) : (
{imageItems.length === 0 ? 'No images added' : 'No image selected'}
)}
{/* Thumbnail Grid */}
{/* Image thumbnails - click to select for preview */} {imageItems.map((img) => ( ))} {/* 360° trigger buttons - click to open 360° view */} {triggerItems.map((img) => ( ))}
); } default: return null; } })}
); }; export default InfoPanelOverlay;