// LightboxModal.tsx import React, { useEffect, useRef, useState } from 'react'; import { Dialog, IconButton, Box, Typography, Tooltip, CircularProgress, } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import DownloadIcon from '@mui/icons-material/Download'; // import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap'; import AddIcon from '@mui/icons-material/Add'; import RemoveIcon from '@mui/icons-material/Remove'; import FitScreenIcon from '@mui/icons-material/FitScreen'; export type LightboxPhoto = { id: number | string; url: string; title?: string; filename?: string; created_at?: string; naturalWidth?: number; naturalHeight?: number; }; type Props = { open: boolean; photos: LightboxPhoto[]; startIndex?: number; onClose: () => void; startIndexCallback?: (idx: number) => void; }; const SWIPE_THRESHOLD = 40; const MIN_SCALE = 0.1; const MAX_SCALE = 5; const SCALE_STEP = 0.25; export default function LightboxModal({ open, photos, startIndex = 0, onClose, startIndexCallback, }: Props) { const [index, setIndex] = useState(startIndex); const [scale, setScale] = useState(1); const [translate, setTranslate] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const [isPanning, setIsPanning] = useState(false); const [isLoading, setIsLoading] = useState(true); const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null); const panStart = useRef<{ x: number; y: number } | null>(null); const lastTranslate = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const touchStartX = useRef(null); const touchDeltaX = useRef(0); // For pinch const pinchStartDist = useRef(null); const pinchStartScale = useRef(1); const imageRef = useRef(null); useEffect(() => setIndex(startIndex), [startIndex, open]); useEffect(() => { window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, index, scale]); useEffect(() => startIndexCallback?.(index), [index]); useEffect(() => { // reset scale/translate when changing image or closing/opening setScale(1); setTranslate({ x: 0, y: 0 }); lastTranslate.current = { x: 0, y: 0 }; setIsLoading(true); setImageDimensions(null); }, [index, open]); const onKey = (e: KeyboardEvent) => { if (!open) return; if (e.key === 'Escape') onClose(); if (e.key === 'ArrowLeft') prev(); if (e.key === 'ArrowRight') next(); if (e.key === '+' || (e.ctrlKey && e.key === '=')) zoomIn(); if (e.key === '-') zoomOut(); if (e.key === '0') fitToScreen(); }; const prev = () => setIndex((i) => (i - 1 + photos.length) % photos.length); const next = () => setIndex((i) => (i + 1) % photos.length); // Preload neighbors useEffect(() => { if (!photos?.length) return; const a = new Image(); a.src = photos[(index - 1 + photos.length) % photos.length]?.url; const b = new Image(); b.src = photos[(index + 1) % photos.length]?.url; }, [index, photos]); const photo = photos && photos.length ? photos[index] : null; // Handle image load const handleImageLoad = (e: React.SyntheticEvent) => { setIsLoading(false); const img = e.currentTarget; setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight }); }; const handleImageError = () => { setIsLoading(false); setImageDimensions(null); }; if (!photo) return null; /* ============================ Zoom / Pan Controls ============================ */ const setScaleClamped = (s: number) => { const clamped = Math.max(MIN_SCALE, Math.min(MAX_SCALE, s)); setScale(clamped); if (clamped === 1) { // reset translate when fully fit setTranslate({ x: 0, y: 0 }); lastTranslate.current = { x: 0, y: 0 }; } }; const zoomIn = () => setScaleClamped(scale + SCALE_STEP); const zoomOut = () => setScaleClamped(scale - SCALE_STEP); const fitToScreen = () => setScaleClamped(1); // eslint-disable-next-line @typescript-eslint/no-unused-vars const handleDoubleClick = (_e: React.MouseEvent) => { if (scale === 1) setScaleClamped(2); else setScaleClamped(1); }; /* ============================ Panning (pointer / touch) ============================ */ const onPointerDown = (e: React.PointerEvent) => { if (scale <= 1) return; (e.target as Element).setPointerCapture?.(e.pointerId); setIsPanning(true); panStart.current = { x: e.clientX, y: e.clientY }; }; const onPointerMove = (e: React.PointerEvent) => { if (!isPanning || !panStart.current) return; const dx = e.clientX - panStart.current.x; const dy = e.clientY - panStart.current.y; const TX = lastTranslate.current.x + dx; const TY = lastTranslate.current.y + dy; setTranslate({ x: TX, y: TY }); }; const onPointerUp = (e: React.PointerEvent) => { if (!isPanning) return; setIsPanning(false); if (panStart.current) { const dx = e.clientX - panStart.current.x; const dy = e.clientY - panStart.current.y; lastTranslate.current = { x: lastTranslate.current.x + dx, y: lastTranslate.current.y + dy }; panStart.current = null; } try { (e.target as Element).releasePointerCapture?.(e.pointerId); } catch { //ignore } }; const onWheel = (e: React.WheelEvent) => { if (!open) return; e.preventDefault(); const delta = -e.deltaY; const step = delta > 0 ? SCALE_STEP : -SCALE_STEP; setScaleClamped(scale + step); }; /* ============================ Touch handlers for swipe + pinch Note: use React.Touch types here to satisfy TS ============================ */ const onTouchStart = (e: React.TouchEvent) => { touchDeltaX.current = 0; if (e.touches.length === 1) { touchStartX.current = e.touches[0].clientX; if (scale > 1) { panStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; setIsPanning(true); } } else if (e.touches.length === 2) { pinchStartDist.current = getDistance(e.touches[0], e.touches[1]); pinchStartScale.current = scale; setIsPanning(false); } }; const onTouchMove = (e: React.TouchEvent) => { if (e.touches.length === 1 && scale > 1 && isPanning && panStart.current) { const dx = e.touches[0].clientX - panStart.current.x; const dy = e.touches[0].clientY - panStart.current.y; setTranslate({ x: lastTranslate.current.x + dx, y: lastTranslate.current.y + dy }); e.preventDefault(); } else if (e.touches.length === 2 && pinchStartDist.current != null) { const dist = getDistance(e.touches[0], e.touches[1]); const ratio = dist / (pinchStartDist.current || dist); setScaleClamped(pinchStartScale.current * ratio); e.preventDefault(); } else if (e.touches.length === 1) { const x = e.touches[0].clientX; touchDeltaX.current = x - (touchStartX.current ?? x); } }; // eslint-disable-next-line @typescript-eslint/no-unused-vars const onTouchEnd = (_e: React.TouchEvent) => { if (isPanning) { setIsPanning(false); lastTranslate.current = { ...translate }; panStart.current = null; } if (pinchStartDist.current != null) { pinchStartDist.current = null; pinchStartScale.current = scale; } if (Math.abs(touchDeltaX.current) > SWIPE_THRESHOLD && Math.abs(translate.x) < 10 && !isPanning) { if (touchDeltaX.current < 0) next(); else prev(); } touchDeltaX.current = 0; touchStartX.current = null; }; // Helper accepting React.Touch (TS-friendly) function getDistance(a: React.Touch, b: React.Touch) { const dx = a.clientX - b.clientX; const dy = a.clientY - b.clientY; return Math.sqrt(dx * dx + dy * dy); } // Calculate image display size based on original dimensions const getImageDisplayStyle = () => { if (!imageDimensions) return {}; const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; const { width: imgWidth, height: imgHeight } = imageDimensions; // Calculate the maximum dimensions to fit in the viewport const maxWidth = windowWidth * 0.9; // 90% of window width const maxHeight = windowHeight * 0.8; // 80% of window height let displayWidth = imgWidth; let displayHeight = imgHeight; // If image is larger than max dimensions, scale it down if (imgWidth > maxWidth || imgHeight > maxHeight) { const widthRatio = maxWidth / imgWidth; const heightRatio = maxHeight / imgHeight; const ratio = Math.min(widthRatio, heightRatio); displayWidth = imgWidth * ratio; displayHeight = imgHeight * ratio; } return { width: `${displayWidth}px`, height: `${displayHeight}px`, maxWidth: '90vw', maxHeight: '80vh', }; }; /* ============================ Render ============================ */ return ( { if (e.target === e.currentTarget) onClose(); }} > {/* Loading indicator */} {isLoading && ( )} {/* Top right controls */} { ev.stopPropagation(); zoomOut(); }} sx={{ bgcolor: 'rgba(255,255,255,0.1)', color: 'white', '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' } }} > { ev.stopPropagation(); fitToScreen(); }} sx={{ bgcolor: 'rgba(255,255,255,0.1)', color: 'white', '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' } }} > { ev.stopPropagation(); zoomIn(); }} sx={{ bgcolor: 'rgba(255,255,255,0.1)', color: 'white', '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' } }} > { ev.stopPropagation(); try { const a = document.createElement('a'); a.href = photo.url; a.download = `${photo.filename ?? photo.title ?? 'image'}`; document.body.appendChild(a); a.click(); a.remove(); } catch (err) { console.error('download', err); } }} sx={{ bgcolor: 'rgba(255,255,255,0.1)', color: 'white', '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' } }} > { ev.stopPropagation(); onClose(); }} sx={{ bgcolor: 'rgba(255,255,255,0.1)', color: 'white', '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' } }} > {/* Prev / Next arrows */} { ev.stopPropagation(); prev(); }} sx={{ position: 'absolute', left: 16, zIndex: 50, color: 'white', bgcolor: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(10px)', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }} > { ev.stopPropagation(); next(); }} sx={{ position: 'absolute', right: 16, zIndex: 50, color: 'white', bgcolor: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(10px)', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }} > {/* Image container */} {photo.title 1 ? (isPanning ? 'grabbing' : 'grab') : 'default', borderRadius: 4, boxShadow: '0 10px 30px rgba(0,0,0,0.6)', touchAction: 'none', ...getImageDisplayStyle(), }} draggable={false} /> {/* Caption */} {photo.title ?? photo.filename} {index + 1} of {photos.length} {photo.created_at && ` • ${new Date(photo.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`} {imageDimensions && ` • ${imageDimensions.width}×${imageDimensions.height}`} {/* Zoom level indicator */} {scale !== 1 && ( {Math.round(scale * 100)}% )} ); }