/** * 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, InfoPanelImageClickAction, InfoPanelLinkClickAction, InfoPanelSectionInstance, } from '../../types/constructor'; import { getInfoPanelSections } from '../../types/constructor'; import { resolveAssetPlaybackUrl } from '../../lib/assetUrl'; import { buildInfoPanelHeaderStyle, buildInfoPanelTitleStyle, buildInfoPanelTextStyle, buildInfoPanelSpanStyle, buildInfoPanelSpanGridStyleWithSection, buildInfoPanelCardStyle, buildInfoPanelCardTitleStyle, buildInfoPanelCardGridStyleWithSection, } 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/video/360 group should open in fullscreen gallery mode */ onOpenGallery?: (items: InfoPanelImage[], initialIndex: number) => void; /** Callback when an image thumbnail is selected */ onSelectImage?: (imageId: string) => void; /** Callback when an Info Panel item targets an internal page slug */ onNavigateToPage?: (targetPageSlug: string) => void; /** Callback when an Info Panel item targets an external URL */ onOpenExternalUrl?: (url: string) => void; /** Callback when a media item should replace the current screen background */ onUseAsBackground?: (image: InfoPanelImage) => void; /** Whether this overlay instance should render the fullscreen backdrop layer */ renderBackdrop?: boolean; /** Optional close handler for backdrop clicks; defaults to onClose */ onBackdropClose?: () => 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 VideoThumbnail: React.FC<{ src: string; caption?: string; }> = ({ src, caption }) => { const [isReady, setIsReady] = useState(false); return (
); }; const InfoPanelOverlay: React.FC = ({ element, onClose, resolveUrl, letterboxStyles, cssVars, onImageClick, onOpenGallery, onSelectImage, onNavigateToPage, onOpenExternalUrl, onUseAsBackground, renderBackdrop = true, onBackdropClose, 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); // 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], ); // Get section instances const sections = useMemo(() => getInfoPanelSections(element), [element]); const openExternalUrl = useCallback( (url: string) => { const trimmed = url.trim(); if (!trimmed) return; if (onOpenExternalUrl) { onOpenExternalUrl(trimmed); return; } const href = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; window.open(href, '_blank', 'noopener,noreferrer'); }, [onOpenExternalUrl], ); const handleLinkDestination = useCallback( (target: { clickAction?: InfoPanelLinkClickAction; targetPageSlug?: string; externalUrl?: string; }): boolean => { if (target.clickAction === 'target_page' && target.targetPageSlug) { onNavigateToPage?.(target.targetPageSlug); return true; } if (target.clickAction === 'external_url' && target.externalUrl) { openExternalUrl(target.externalUrl); return true; } return false; }, [onNavigateToPage, openExternalUrl], ); const handleImageDestination = useCallback( ( image: InfoPanelImage, section: InfoPanelSectionInstance, items?: InfoPanelImage[], ) => { if (image.useAsBackground) { onUseAsBackground?.(image); return; } const clickAction: InfoPanelImageClickAction = image.clickAction || 'panel'; if (clickAction === 'target_page' && image.targetPageSlug) { onNavigateToPage?.(image.targetPageSlug); return; } if (clickAction === 'external_url' && image.externalUrl) { openExternalUrl(image.externalUrl); return; } const sectionMode = section.mediaOpenMode || (clickAction === 'fullscreen' ? 'fullscreen' : 'panel'); if (sectionMode === 'fullscreen' && onOpenGallery && items?.length) { onOpenGallery( items, Math.max( 0, items.findIndex((item) => item.id === image.id), ), ); return; } onImageClick(image); }, [ onNavigateToPage, onImageClick, onOpenGallery, onUseAsBackground, openExternalUrl, ], ); const getClickableStyle = ( target: { clickAction?: InfoPanelLinkClickAction; targetPageSlug?: string; externalUrl?: string; }, baseStyle: React.CSSProperties, ): React.CSSProperties => ({ ...baseStyle, cursor: target.clickAction ? 'pointer' : baseStyle.cursor, }); // 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) { (onBackdropClose || onClose)(); } }, [onBackdropClose, onClose, isEditMode], ); // Handle touch on backdrop const handleBackdropTouch = useCallback( (e: React.TouchEvent) => { if (e.target === overlayRef.current && !isEditMode) { (onBackdropClose || onClose)(); } }, [onBackdropClose, 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 */} {renderBackdrop && (
)} {/* 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; const headerClickProps = section.clickAction ? { role: 'button' as const, tabIndex: 0, onClick: () => handleLinkDestination(section), onKeyDown: ( event: React.KeyboardEvent, ) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleLinkDestination(section); } }, } : {}; // 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 (

handleLinkDestination(section)} onKeyDown={(event) => { if ( section.clickAction && (event.key === 'Enter' || event.key === ' ') ) { event.preventDefault(); handleLinkDestination(section); } }} > {titleContent}

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

handleLinkDestination(section)} onKeyDown={(event) => { if ( section.clickAction && (event.key === 'Enter' || event.key === ' ') ) { event.preventDefault(); handleLinkDestination(section); } }} > {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) => ( ))}
); } 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 || []; // Nothing to show if no items if (sectionImages.length === 0) return null; // 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 (
{/* Thumbnail Grid */}
{sectionImages.map((img) => { const itemType = img.itemType || 'image'; const is360 = itemType === '360'; const isVideo = itemType === 'video'; return ( ); })}
); } default: return null; } })}
); }; export default InfoPanelOverlay;