39948-vm/frontend/src/components/UiElements/ImageDetailPanel.tsx
2026-05-30 11:15:50 +02:00

592 lines
20 KiB
TypeScript

/**
* 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<ImageDetailPanelProps> = ({
element,
image,
onClose,
resolveUrl,
letterboxStyles,
isEditMode = false,
onDetailPositionChange,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const panelRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(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<void> }).webkitRequestFullscreen) {
// Safari fallback
await (panel as HTMLDivElement & { webkitRequestFullscreen: () => Promise<void> }).webkitRequestFullscreen();
}
} else {
// Exit fullscreen
if (document.exitFullscreen) {
await document.exitFullscreen();
} else if ((document as Document & { webkitExitFullscreen?: () => Promise<void> }).webkitExitFullscreen) {
// Safari fallback
await (document as Document & { webkitExitFullscreen: () => Promise<void> }).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 */}
<div
className='fixed inset-0 z-[52] overflow-hidden'
style={{
backgroundColor: 'transparent',
pointerEvents: isEditMode ? 'none' : 'auto',
}}
onClick={handleBackdropClick}
/>
{/* Inner container constrained to canvas bounds */}
<div
ref={containerRef}
className='fixed inset-0 z-[54] overflow-hidden pointer-events-none'
style={letterboxStyles || { position: 'absolute', inset: 0 }}
>
{/* Detail Panel */}
<div
ref={panelRef}
role='dialog'
aria-modal='true'
aria-label={image?.caption || 'Image detail'}
tabIndex={-1}
style={{
...panelStyle,
pointerEvents: 'auto', // Ensure panel receives events
cursor:
isEditMode && onDetailPositionChange
? isDragging
? 'grabbing'
: 'grab'
: undefined,
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={
isEditMode && onDetailPositionChange ? handleDragStart : undefined
}
>
{/* Control buttons - only show for regular images (embeds have their own controls) */}
{!isEmbed && image?.imageUrl && (
<>
{/* Close button */}
<button
type='button'
className='absolute top-2 right-2 z-10 p-1 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors'
onClick={handleClose}
aria-label='Close detail view'
>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={2}
stroke='currentColor'
className='w-5 h-5'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M6 18 18 6M6 6l12 12'
/>
</svg>
</button>
{/* Fullscreen toggle button */}
<button
type='button'
className='absolute top-2 right-10 z-10 p-1 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors'
onClick={toggleFullscreen}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? (
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={2}
stroke='currentColor'
className='w-5 h-5'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25'
/>
</svg>
) : (
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={2}
stroke='currentColor'
className='w-5 h-5'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15'
/>
</svg>
)}
</button>
</>
)}
{/* Loading spinner */}
{isLoading && (
<div className='absolute inset-0 flex items-center justify-center'>
<div className='w-10 h-10 border-4 border-white/20 border-t-white rounded-full animate-spin' />
</div>
)}
{/* Content area - disable pointer events in edit mode so panel can be dragged */}
<div
className='w-full h-full'
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
>
{!hasImage ? (
// Edit mode placeholder when no image selected
<div className='w-full h-full flex flex-col items-center justify-center text-white/50'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-16 h-16 mb-3'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'
/>
</svg>
<p className='text-sm font-medium'>Image Detail Panel</p>
<p className='text-xs mt-1 text-white/30'>
Click an image in Info Panel to preview
</p>
</div>
) : isEmbed ? (
// Embed iframe
isValidEmbed ? (
<iframe
src={embedUrl}
title={image?.caption || 'Embedded content'}
className='w-full h-full border-0'
sandbox='allow-scripts allow-same-origin allow-presentation allow-popups'
allow='accelerometer; autoplay; fullscreen; gyroscope; xr-spatial-tracking'
loading='lazy'
onLoad={handleIframeLoad}
onError={handleIframeError}
style={{
opacity: isLoading ? 0 : 1,
WebkitOverflowScrolling: 'touch',
}}
/>
) : (
// Invalid embed URL error
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-12 h-12 mb-2 text-red-400'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z'
/>
</svg>
<p className='text-sm'>Invalid or unsupported embed URL</p>
<p className='text-xs mt-1 text-white/40'>
Only trusted domains are allowed
</p>
</div>
)
) : image?.imageUrl ? (
// Regular image
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(image.imageUrl)}
alt={image?.caption || ''}
className='w-full h-full object-contain'
draggable={false}
onLoad={handleImageLoad}
style={{
opacity: isLoading ? 0 : 1,
transition: 'opacity 200ms ease-out',
}}
/>
) : (
// No content placeholder
<div className='w-full h-full flex items-center justify-center text-white/40'>
<p>No content</p>
</div>
)}
{/* Embed error state */}
{embedError && (
<div className='absolute inset-0 flex flex-col items-center justify-center text-white/60 bg-black/50'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-12 h-12 mb-2 text-red-400'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z'
/>
</svg>
<p className='text-sm'>Failed to load embed</p>
</div>
)}
</div>
{/* Caption */}
{image?.caption && (
<div className='absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3'>
<p className='text-sm text-white' style={captionFontStyle}>
{image.caption}
</p>
</div>
)}
</div>
</div>
</>
);
};
export default ImageDetailPanel;