592 lines
20 KiB
TypeScript
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;
|