592 lines
22 KiB
TypeScript
592 lines
22 KiB
TypeScript
// 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>
|
||
);
|
||
} |