TheKalawati/src/WeddingGallery/LightboxModal.tsx

592 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<number>(startIndex);
const [scale, setScale] = useState<number>(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<number | null>(null);
const touchDeltaX = useRef(0);
// For pinch
const pinchStartDist = useRef<number | null>(null);
const pinchStartScale = useRef<number>(1);
const imageRef = useRef<HTMLImageElement>(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<HTMLImageElement>) => {
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 (
<Dialog
open={open}
onClose={onClose}
fullScreen
PaperProps={{
sx: {
m: 0,
p: 0,
height: '100vh',
backgroundColor: 'rgba(0,0,0,0.95)',
overflow: 'hidden',
'& .MuiDialog-container': {
alignItems: 'center',
justifyContent: 'center',
}
},
}}
>
<Box
sx={{
width: '100%',
height: '100%',
position: 'relative',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: isPanning ? 'none' : 'auto',
touchAction: 'none',
}}
onWheel={onWheel}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
{/* Loading indicator */}
{isLoading && (
<Box sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 10
}}>
<CircularProgress sx={{ color: 'rgba(255,255,255,0.7)' }} />
</Box>
)}
{/* Top right controls */}
<Box sx={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 60,
display: 'flex',
gap: 1,
background: 'rgba(0,0,0,0.5)',
borderRadius: 2,
p: 0.5,
backdropFilter: 'blur(10px)',
}}>
<Tooltip title="Zoom Out">
<IconButton
size="small"
onClick={(ev) => {
ev.stopPropagation();
zoomOut();
}}
sx={{
bgcolor: 'rgba(255,255,255,0.1)',
color: 'white',
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }
}}
>
<RemoveIcon />
</IconButton>
</Tooltip>
<Tooltip title="Fit to screen">
<IconButton
size="small"
onClick={(ev) => {
ev.stopPropagation();
fitToScreen();
}}
sx={{
bgcolor: 'rgba(255,255,255,0.1)',
color: 'white',
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }
}}
>
<FitScreenIcon />
</IconButton>
</Tooltip>
<Tooltip title="Zoom In">
<IconButton
size="small"
onClick={(ev) => {
ev.stopPropagation();
zoomIn();
}}
sx={{
bgcolor: 'rgba(255,255,255,0.1)',
color: 'white',
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }
}}
>
<AddIcon />
</IconButton>
</Tooltip>
<Tooltip title="Download">
<IconButton
size="small"
onClick={(ev) => {
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)' }
}}
>
<DownloadIcon />
</IconButton>
</Tooltip>
<Tooltip title="Close (Esc)">
<IconButton
size="small"
onClick={(ev) => {
ev.stopPropagation();
onClose();
}}
sx={{
bgcolor: 'rgba(255,255,255,0.1)',
color: 'white',
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }
}}
>
<CloseIcon />
</IconButton>
</Tooltip>
</Box>
{/* Prev / Next arrows */}
<IconButton
onClick={(ev) => {
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)' }
}}
>
<ArrowBackIosNewIcon />
</IconButton>
<IconButton
onClick={(ev) => {
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)' }
}}
>
<ArrowForwardIosIcon />
</IconButton>
{/* Image container */}
<Box
sx={{
width: '100%',
height: '100%',
px: 2,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onDoubleClick={handleDoubleClick}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
touchAction: 'none',
position: 'relative',
}}
>
<img
ref={imageRef}
src={photo.url}
alt={photo.title ?? photo.filename}
onLoad={handleImageLoad}
onError={handleImageError}
style={{
display: isLoading ? 'none' : 'block',
width: 'auto',
height: 'auto',
maxWidth: '90vw',
maxHeight: '80vh',
objectFit: 'contain',
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
transition: isPanning ? 'none' : 'transform 120ms ease',
cursor: scale > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
borderRadius: 4,
boxShadow: '0 10px 30px rgba(0,0,0,0.6)',
touchAction: 'none',
...getImageDisplayStyle(),
}}
draggable={false}
/>
</Box>
</Box>
{/* Caption */}
<Box sx={{
position: 'absolute',
bottom: 16,
left: 16,
right: 16,
textAlign: 'center',
zIndex: 50,
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(10px)',
borderRadius: 2,
p: 1.5,
maxWidth: '600px',
mx: 'auto',
}}>
<Typography variant="subtitle1" color="white" noWrap sx={{ fontWeight: 500 }}>
{photo.title ?? photo.filename}
</Typography>
<Typography variant="caption" color="rgba(255,255,255,0.85)" sx={{ display: 'block', mt: 0.5 }}>
{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}`}
</Typography>
</Box>
{/* Zoom level indicator */}
{scale !== 1 && (
<Box sx={{
position: 'absolute',
top: 16,
left: 16,
zIndex: 50,
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(10px)',
borderRadius: 2,
p: 1,
color: 'white',
fontSize: '0.875rem',
fontWeight: 500,
}}>
{Math.round(scale * 100)}%
</Box>
)}
</Box>
</Dialog>
);
}