import { useState, useCallback, useEffect, useRef } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faXmark, faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons'; import { lightboxOpenRef } from '../utils/lightboxState'; import { useImageProxy, useProxiedUrl } from '../settings/ImageProxyProvider'; import { preloadImage } from '../utils/imagePreload'; export interface LightboxImage { fullsize: string; alt?: string; } interface LightboxProps { images: LightboxImage[]; initialIndex?: number; onClose: () => void; } const MIN_ZOOM = 1; const MAX_ZOOM = 10; const ZOOM_STEP = 0.15; function LightboxImageItem({ img, index, panX, panY, zoom, isPanning, loaded, markActiveProxyFailed }: { img: LightboxImage; index: number; panX: number; panY: number; zoom: number; isPanning: boolean; loaded: Set; markActiveProxyFailed: () => void; }) { const proxiedFullsize = useProxiedUrl(img.fullsize); return ( {img.alt {}} // Handle elsewhere or wrap onError={() => markActiveProxyFailed()} draggable={false} style={{ transform: `translate(${panX}px, ${panY}px) scale(${zoom})`, transition: isPanning ? 'none' : 'transform 0.15s ease-out', }} /> ); } export default function Lightbox({ images, initialIndex = 0, onClose }: LightboxProps) { const [index, setIndex] = useState(initialIndex); const [loaded, setLoaded] = useState>(new Set()); const { markActiveProxyFailed } = useImageProxy(); const [zoom, setZoom] = useState(MIN_ZOOM); const [panX, setPanX] = useState(0); const [panY, setPanY] = useState(0); const [isPanning, setIsPanning] = useState(false); const [touchCount, setTouchCount] = useState(0); const dragStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); const pinchStartRef = useRef({ dist: 0, zoom: 1, centerX: 0, centerY: 0, panX: 0, panY: 0 }); const didDragRef = useRef(false); const lastTapRef = useRef<{ time: number; x: number; y: number } | null>(null); const backdropRef = useRef(null); const zoomResetRef = useRef(MIN_ZOOM); useEffect(() => { zoomResetRef.current = zoom; }, [zoom]); const resetView = useCallback(() => { setZoom(MIN_ZOOM); setPanX(0); setPanY(0); }, []); const goTo = useCallback( (i: number) => { setIndex(((i % images.length) + images.length) % images.length); resetView(); }, [images.length, resetView], ); const prev = useCallback(() => goTo(index - 1), [goTo, index]); const next = useCallback(() => goTo(index + 1), [goTo, index]); useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); else if (e.key === 'ArrowLeft') prev(); else if (e.key === 'ArrowRight') next(); else if (e.key === '0') resetView(); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [onClose, prev, next, resetView]); useEffect(() => { const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, []); // Preload all sibling images when the lightbox opens, and preload // adjacent images whenever the active index changes. useEffect(() => { if (images.length <= 1) return; // On mount, preload every image in the gallery. images.forEach((img) => preloadImage(img.fullsize)); }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (images.length <= 1) return; // When the index changes, make sure the immediate neighbours are cached. const prevIdx = (index - 1 + images.length) % images.length; const nextIdx = (index + 1) % images.length; preloadImage(images[prevIdx].fullsize); preloadImage(images[nextIdx].fullsize); }, [index, images]); const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); e.stopPropagation(); const backdrop = backdropRef.current; if (!backdrop) return; const rect = backdrop.getBoundingClientRect(); const cursorX = e.clientX - rect.left - rect.width / 2; const cursorY = e.clientY - rect.top - rect.height / 2; const currentZoom = zoomResetRef.current; const direction = e.deltaY < 0 ? 1 : -1; const factor = 1 + ZOOM_STEP * direction; const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, currentZoom * factor)); if (newZoom === currentZoom) return; const ratio = newZoom / currentZoom; setPanX((prev) => cursorX - ratio * (cursorX - prev)); setPanY((prev) => cursorY - ratio * (cursorY - prev)); setZoom(newZoom); }, []); useEffect(() => { if (!isPanning) return; const onMouseMove = (e: MouseEvent) => { e.preventDefault(); const dx = e.clientX - dragStartRef.current.x; const dy = e.clientY - dragStartRef.current.y; if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { didDragRef.current = true; } setPanX(dragStartRef.current.panX + dx); setPanY(dragStartRef.current.panY + dy); }; const onMouseUp = () => { setIsPanning(false); }; window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); document.body.style.cursor = 'grabbing'; return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); document.body.style.cursor = ''; }; }, [isPanning]); const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); didDragRef.current = false; dragStartRef.current = { x: e.clientX, y: e.clientY, panX, panY }; setIsPanning(true); }, [panX, panY], ); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (zoom > MIN_ZOOM) { resetView(); } else { const backdrop = backdropRef.current; if (!backdrop) return; const rect = backdrop.getBoundingClientRect(); const cursorX = e.clientX - rect.left - rect.width / 2; const cursorY = e.clientY - rect.top - rect.height / 2; const newZoom = 3; const ratio = newZoom / MIN_ZOOM; setPanX(cursorX - ratio * (cursorX - 0)); setPanY(cursorY - ratio * (cursorY - 0)); setZoom(newZoom); } }, [zoom, resetView], ); const handleBackdropClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); if (didDragRef.current) { didDragRef.current = false; return; } if (zoom > MIN_ZOOM) { resetView(); return; } onClose(); }, [zoom, resetView, onClose], ); const handleTouchStart = useCallback( (e: React.TouchEvent) => { if (e.touches.length === 1) { const touch = e.touches[0]; didDragRef.current = false; dragStartRef.current = { x: touch.clientX, y: touch.clientY, panX, panY }; setTouchCount(1); } else if (e.touches.length === 2) { e.preventDefault(); // Prevent accidental zoom/scrolling by browser const t1 = e.touches[0]; const t2 = e.touches[1]; const dist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); const centerX = (t1.clientX + t2.clientX) / 2; const centerY = (t1.clientY + t2.clientY) / 2; const backdrop = backdropRef.current; if (backdrop) { const rect = backdrop.getBoundingClientRect(); const relativeCenterX = centerX - rect.left - rect.width / 2; const relativeCenterY = centerY - rect.top - rect.height / 2; pinchStartRef.current = { dist, zoom, centerX: relativeCenterX, centerY: relativeCenterY, panX, panY, }; } setTouchCount(2); } }, [panX, panY, zoom], ); const handleTouchMove = useCallback( (e: React.TouchEvent) => { if (e.touches.length === 1 && touchCount === 1) { if (zoomResetRef.current <= MIN_ZOOM) return; const touch = e.touches[0]; const dx = touch.clientX - dragStartRef.current.x; const dy = touch.clientY - dragStartRef.current.y; if (Math.abs(dx) > 4 || Math.abs(dy) > 4) { didDragRef.current = true; } setPanX(dragStartRef.current.panX + dx); setPanY(dragStartRef.current.panY + dy); } else if (e.touches.length === 2 && touchCount === 2) { e.preventDefault(); didDragRef.current = true; const t1 = e.touches[0]; const t2 = e.touches[1]; const dist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); const ratio = dist / pinchStartRef.current.dist; const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, pinchStartRef.current.zoom * ratio)); const actualRatio = newZoom / pinchStartRef.current.zoom; const { centerX: startCenterX, centerY: startCenterY, panX: startPanX, panY: startPanY } = pinchStartRef.current; const backdrop = backdropRef.current; if (backdrop) { const rect = backdrop.getBoundingClientRect(); const currentCenterX = (t1.clientX + t2.clientX) / 2 - rect.left - rect.width / 2; const currentCenterY = (t1.clientY + t2.clientY) / 2 - rect.top - rect.height / 2; const zoomPanX = startCenterX - actualRatio * (startCenterX - startPanX); const zoomPanY = startCenterY - actualRatio * (startCenterY - startPanY); const centerDeltaX = currentCenterX - startCenterX; const centerDeltaY = currentCenterY - startCenterY; setPanX(zoomPanX + centerDeltaX); setPanY(zoomPanY + centerDeltaY); } setZoom(newZoom); } }, [touchCount], ); const handleTouchEnd = useCallback( (e: React.TouchEvent) => { if (e.touches.length === 0) { const wasTwoFinger = touchCount === 2; setTouchCount(0); if (!wasTwoFinger && !didDragRef.current) { const changedTouch = e.changedTouches[0]; const now = Date.now(); const lastTap = lastTapRef.current; if ( lastTap && now - lastTap.time < 300 && Math.abs(changedTouch.clientX - lastTap.x) < 30 && Math.abs(changedTouch.clientY - lastTap.y) < 30 ) { lastTapRef.current = null; if (zoom > MIN_ZOOM) { resetView(); } else { const backdrop = backdropRef.current; if (backdrop) { const rect = backdrop.getBoundingClientRect(); const cursorX = changedTouch.clientX - rect.left - rect.width / 2; const cursorY = changedTouch.clientY - rect.top - rect.height / 2; const newZoom = 3; const ratio = newZoom / MIN_ZOOM; setPanX(cursorX - ratio * (cursorX - 0)); setPanY(cursorY - ratio * (cursorY - 0)); setZoom(newZoom); } } return; } lastTapRef.current = { time: now, x: changedTouch.clientX, y: changedTouch.clientY }; } if (didDragRef.current) { setTimeout(() => { didDragRef.current = false; }, 50); } } else if (e.touches.length === 1) { const touch = e.touches[0]; dragStartRef.current = { x: touch.clientX, y: touch.clientY, panX, panY }; setTouchCount(1); } }, [panX, panY, touchCount, zoom, resetView], ); const img = images[index]; const hasPrev = images.length > 1; const hasNext = images.length > 1; const cursorStyle = isPanning ? 'grabbing' : zoom > MIN_ZOOM ? 'grab' : 'default'; return (
{images.length > 1 && (
{index + 1} / {images.length}
)} {zoom > MIN_ZOOM && (
e.stopPropagation()}> {Math.round(zoom * 100)}%
)}
e.stopPropagation()} style={{ pointerEvents: 'none' }} > {/* We need to keep the onLoad on the original or wrap it */} setLoaded((prev) => new Set(prev).add(index))} />
{hasPrev && ( )} {hasNext && ( )} {img.alt && (
e.stopPropagation()}> {img.alt}
)}
); } export function useLightbox() { const [lightboxImages, setLightboxImages] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(0); const ownsHistoryEntry = useRef(false); useEffect(() => { lightboxOpenRef.current = lightboxImages !== null; }, [lightboxImages]); useEffect(() => { if (lightboxImages !== null) { window.history.pushState({ lightbox: true }, '', '#lightbox'); ownsHistoryEntry.current = true; } }, [lightboxImages]); useEffect(() => { const onPopState = () => { if (ownsHistoryEntry.current) { ownsHistoryEntry.current = false; setLightboxImages(null); } }; window.addEventListener('popstate', onPopState); return () => window.removeEventListener('popstate', onPopState); }, []); const openLightbox = useCallback((images: import('./Lightbox').LightboxImage[], index = 0) => { setLightboxIndex(index); setLightboxImages(images); }, []); const closeLightbox = useCallback(() => { if (ownsHistoryEntry.current) { ownsHistoryEntry.current = false; window.history.back(); } setLightboxImages(null); }, []); const lightboxElement = lightboxImages !== null ? ( ) : null; return { openLightbox, closeLightbox, lightboxElement }; }