/** * ImageDetailPanel Component * * Displays enlarged image or 360/iframe embed. * Positioned absolutely within the canvas (not fullscreen). * Supports embed URL validation for security. */ import React, { useState, useEffect, useCallback, useRef, useMemo, } from 'react'; import type { CanvasElement, InfoPanelImage } from '../../types/constructor'; import { resolveAssetPlaybackUrl } from '../../lib/assetUrl'; import { getFontByKey, getFontStyle } from '../../lib/fonts'; /** * Allowed embed domains for security */ const ALLOWED_EMBED_DOMAINS = [ 'matterport.com', 'my.matterport.com', 'kuula.co', 'roundme.com', 'sketchfab.com', 'youtube.com', 'www.youtube.com', 'vimeo.com', 'player.vimeo.com', 'google.com', 'maps.google.com', 'www.google.com', 'docs.google.com', 'drive.google.com', '360stories.com', ]; /** * Validate if the URL is from an allowed embed domain */ const isValidEmbedUrl = (url: string): boolean => { try { const parsed = new URL(url); return ALLOWED_EMBED_DOMAINS.some( (domain) => parsed.hostname === domain || parsed.hostname.endsWith('.' + domain), ); } catch { return false; } }; interface ImageDetailPanelProps { element: CanvasElement; image: InfoPanelImage | null; onClose: () => void; resolveUrl?: (url: string | undefined) => string; letterboxStyles?: React.CSSProperties; isEditMode?: boolean; onDetailPositionChange?: (xPercent: number, yPercent: number) => void; } const ImageDetailPanel: React.FC = ({ element, image, onClose, resolveUrl, letterboxStyles, isEditMode = false, onDetailPositionChange, }) => { const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const panelRef = useRef(null); const containerRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const [isLoading, setIsLoading] = useState(true); const [embedError, setEmbedError] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); // Drag state for edit mode const [isDragging, setIsDragging] = useState(false); const dragStartRef = useRef<{ x: number; y: number; panelX: number; panelY: number; } | null>(null); // Fade in animation useEffect(() => { requestAnimationFrame(() => setIsVisible(true)); }, []); // Keyboard navigation (ESC to close) - only when not in fullscreen // (fullscreen mode handles ESC itself to exit fullscreen first) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && !document.fullscreenElement) { e.stopPropagation(); onClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); // Focus the panel on mount useEffect(() => { const panel = panelRef.current; if (!panel) return; panel.focus(); }, []); // Handle fullscreen change events (user may exit via ESC or browser controls) useEffect(() => { const handleFullscreenChange = () => { setIsFullscreen(!!document.fullscreenElement); }; document.addEventListener('fullscreenchange', handleFullscreenChange); document.addEventListener('webkitfullscreenchange', handleFullscreenChange); return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange); document.removeEventListener('webkitfullscreenchange', handleFullscreenChange); }; }, []); // Toggle fullscreen mode const toggleFullscreen = useCallback(async () => { const panel = panelRef.current; if (!panel) return; try { if (!document.fullscreenElement) { // Enter fullscreen if (panel.requestFullscreen) { await panel.requestFullscreen(); } else if ((panel as HTMLDivElement & { webkitRequestFullscreen?: () => Promise }).webkitRequestFullscreen) { // Safari fallback await (panel as HTMLDivElement & { webkitRequestFullscreen: () => Promise }).webkitRequestFullscreen(); } } else { // Exit fullscreen if (document.exitFullscreen) { await document.exitFullscreen(); } else if ((document as Document & { webkitExitFullscreen?: () => Promise }).webkitExitFullscreen) { // Safari fallback await (document as Document & { webkitExitFullscreen: () => Promise }).webkitExitFullscreen(); } } } catch { // Fullscreen may be blocked or not supported (iOS Safari) - silently ignore } }, []); // Handle backdrop click (disabled in edit mode) const handleBackdropClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget && !isEditMode) { // Exit fullscreen first if active, then close if (document.fullscreenElement) { // eslint-disable-next-line @typescript-eslint/no-empty-function document.exitFullscreen().catch(() => {}); } onClose(); } }, [onClose, isEditMode], ); // Handle close with fullscreen exit const handleClose = useCallback(async () => { // Exit fullscreen before closing if in fullscreen mode if (document.fullscreenElement) { try { await document.exitFullscreen(); } catch { // Ignore errors } } onClose(); }, [onClose]); // Extract detail panel styling from element const detailXPercent = element.detailXPercent ?? 75; const detailYPercent = element.detailYPercent ?? 50; const detailWidth = element.detailWidth ?? '500'; const detailHeight = element.detailHeight ?? '400'; const detailBackgroundColor = element.detailBackgroundColor ?? 'rgba(0, 0, 0, 0.9)'; const detailBorderRadius = element.detailBorderRadius ?? '12'; const detailBorderWidth = element.detailBorderWidth ?? '0'; const detailBorderColor = element.detailBorderColor ?? 'transparent'; const detailBorderStyle = element.detailBorderStyle ?? 'solid'; const detailPadding = element.detailPadding ?? '12'; // Caption font style const captionFontStyle = useMemo(() => { const fontKey = element.detailCaptionFontFamily; if (!fontKey) return {}; const font = getFontByKey(fontKey); return font ? getFontStyle(font) : {}; }, [element.detailCaptionFontFamily]); // Note: ImageDetailPanel doesn't render its own overlay backdrop. // The parent InfoPanelOverlay already provides the backdrop when the info panel is open. // Determine content type (handle null image for edit mode placeholder) const isEmbed = image?.itemType === '360' && image?.embedUrl; const embedUrl = image?.embedUrl ?? ''; const isValidEmbed = isEmbed && isValidEmbedUrl(embedUrl); const hasImage = !!image; // 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 - fullscreen mode overrides positioning and sizing const panelStyle: React.CSSProperties = isFullscreen ? { position: 'fixed', inset: 0, width: '100%', height: '100%', backgroundColor: '#000', borderRadius: 0, padding: 0, overflow: 'hidden', opacity: 1, border: 'none', zIndex: 9999, } : { position: 'absolute', left: `${detailXPercent}%`, top: `${detailYPercent}%`, transform: 'translate(-50%, -50%)', width: `min(${toCU(detailWidth)}, calc(100vw - 32px))`, height: `min(${toCU(detailHeight)}, calc(100dvh - 64px))`, backgroundColor: detailBackgroundColor, borderRadius: toCU(detailBorderRadius), padding: isEmbed ? 0 : toCU(detailPadding), overflow: 'hidden', opacity: isVisible ? 1 : 0, transition: 'opacity 200ms ease-out', border: detailBorderWidth !== '0' && detailBorderWidth ? `${toCU(detailBorderWidth)} ${detailBorderStyle} ${detailBorderColor}` : 'none', }; // Handle iframe load const handleIframeLoad = () => { setIsLoading(false); }; // Handle iframe error const handleIframeError = () => { setIsLoading(false); setEmbedError(true); }; // Handle image load const handleImageLoad = () => { setIsLoading(false); }; // Drag handlers for edit mode const handleDragStart = useCallback( (e: React.MouseEvent) => { if (!isEditMode || !onDetailPositionChange) return; e.preventDefault(); e.stopPropagation(); setIsDragging(true); dragStartRef.current = { x: e.clientX, y: e.clientY, panelX: element.detailXPercent ?? 75, panelY: element.detailYPercent ?? 50, }; }, [ isEditMode, onDetailPositionChange, element.detailXPercent, element.detailYPercent, ], ); useEffect(() => { if (!isDragging || !containerRef.current) return; const container = containerRef.current; const handleMouseMove = (e: MouseEvent) => { if (!dragStartRef.current) return; 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), ); onDetailPositionChange?.(Math.round(newX), Math.round(newY)); }; const handleMouseUp = () => { setIsDragging(false); dragStartRef.current = null; }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isDragging, onDetailPositionChange]); return ( <> {/* Click backdrop (transparent) - InfoPanelOverlay provides the visual backdrop */}
{/* Inner container constrained to canvas bounds */}
{/* Detail Panel */}
e.stopPropagation()} onMouseDown={ isEditMode && onDetailPositionChange ? handleDragStart : undefined } > {/* Control buttons - only show for regular images (embeds have their own controls) */} {!isEmbed && image?.imageUrl && ( <> {/* Close button */} {/* Fullscreen toggle button */} )} {/* Loading spinner */} {isLoading && (
)} {/* Content area - disable pointer events in edit mode so panel can be dragged */}
{!hasImage ? ( // Edit mode placeholder when no image selected

Image Detail Panel

Click an image in Info Panel to preview

) : isEmbed ? ( // Embed iframe isValidEmbed ? (