Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
16 kB · 514 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515import { 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<number>; markActiveProxyFailed: () => void;}) { const proxiedFullsize = useProxiedUrl(img.fullsize); return ( <img src={proxiedFullsize} alt={img.alt || ''} className={`lightbox-image ${loaded.has(index) ? 'lightbox-image--loaded' : ''}`} onLoad={() => {}} // 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<Set<number>>(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<HTMLDivElement>(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 ( <div ref={backdropRef} className="lightbox-backdrop" onClick={handleBackdropClick} onWheel={handleWheel} onMouseDown={handleMouseDown} onDoubleClick={handleDoubleClick} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} role="dialog" aria-modal="true" aria-label="Image viewer" style={{ cursor: cursorStyle }} > <button className="lightbox-close" onClick={(e) => { e.stopPropagation(); onClose(); }} aria-label="Close" > <FontAwesomeIcon icon={faXmark} /> </button>
{images.length > 1 && ( <div className="lightbox-counter"> {index + 1} / {images.length} </div> )}
{zoom > MIN_ZOOM && ( <div className="lightbox-zoom-indicator" onClick={(e) => e.stopPropagation()}> {Math.round(zoom * 100)}% </div> )}
<div className="lightbox-image-container" onClick={(e) => e.stopPropagation()} style={{ pointerEvents: 'none' }} > <LightboxImageItem img={img} index={index} panX={panX} panY={panY} zoom={zoom} isPanning={isPanning} loaded={loaded} markActiveProxyFailed={markActiveProxyFailed} /> {/* We need to keep the onLoad on the original or wrap it */} <img src={useProxiedUrl(img.fullsize)} alt="" style={{ display: 'none' }} onLoad={() => setLoaded((prev) => new Set(prev).add(index))} /> </div>
{hasPrev && ( <button className="lightbox-nav lightbox-nav--prev" onClick={(e) => { e.stopPropagation(); prev(); }} aria-label="Previous image" > <FontAwesomeIcon icon={faChevronLeft} /> </button> )} {hasNext && ( <button className="lightbox-nav lightbox-nav--next" onClick={(e) => { e.stopPropagation(); next(); }} aria-label="Next image" > <FontAwesomeIcon icon={faChevronRight} /> </button> )}
{img.alt && ( <div className="lightbox-alt" onClick={(e) => e.stopPropagation()}> {img.alt} </div> )} </div> );}
export function useLightbox() { const [lightboxImages, setLightboxImages] = useState<import('./Lightbox').LightboxImage[] | null>(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 ? ( <Lightbox images={lightboxImages} initialIndex={lightboxIndex} onClose={closeLightbox} /> ) : null;
return { openLightbox, closeLightbox, lightboxElement };}