'use client'; /** * ImageViewer - Image viewer with zoom, pan, rotate, flip, gallery navigation * * Two presentations share one engine (react-zoom-pan-pinch): * - Embedded (default): checkerboard canvas + editing toolbar (zoom presets, * rotate, flip, expand-to-fullscreen). * - Lightbox (`lightbox` / `inDialog`): macOS Quick-Look-grade fullscreen — * clean dark backdrop, idle-fading floating chrome, no rotate/flip slab. * * Gesture model (macOS-native): * - Trackpad pinch arrives as wheel events with `ctrlKey=true`; the library's * `smooth` path maps `|deltaY|` to the zoom magnitude so scale tracks the * gesture 1:1. We raise `smoothStep` (see WHEEL_SMOOTH_STEP) so a real pinch * reaches a meaningful zoom in one motion. * - Zoom is anchored to the FOCAL POINT (cursor / pinch midpoint) — the point * under the fingers stays put — handled natively by the library. * - Pan by drag when zoomed; double-click/tap toggles fit ↔ zoom toward the * point; keyboard +/-/0 and arrows (pan when zoomed, gallery nav at fit). */ import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { ImageIcon, AlertCircle, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { TransformWrapper, TransformComponent, type ReactZoomPanPinchRef, } from 'react-zoom-pan-pinch'; import { cn, Dialog, DialogContent, DialogTitle, Alert, AlertDescription, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core'; import { useAppT } from '@djangocfg/i18n'; import { useHotkey } from '@djangocfg/ui-core/hooks'; import { ImageToolbar } from './ImageToolbar'; import { ImageInfo } from './ImageInfo'; import { LightboxChrome } from './LightboxChrome'; import { useImageTransform, useImageLoading, useIdleChrome } from '../hooks'; import { KEYBOARD_PAN_STEP, MIN_ZOOM, MAX_ZOOM, WHEEL_SMOOTH_STEP, PINCH_STEP, DOUBLE_CLICK_STEP, } from '../utils'; import type { ImageViewerProps } from '../types'; // ============================================================================= // COMPONENT // ============================================================================= export function ImageViewer({ images, initialIndex = 0, inDialog = false, lightbox = false, onRequestClose, autoFocus = false, }: ImageViewerProps) { const t = useAppT(); // `lightbox` implies the in-dialog presentation. const isLightbox = lightbox || inDialog; const [currentIndex, setCurrentIndex] = useState(() => Math.max(0, Math.min(initialIndex, images.length - 1)) ); // Reset index when initialIndex changes (e.g. opening different image) useEffect(() => { setCurrentIndex(Math.max(0, Math.min(initialIndex, images.length - 1))); }, [initialIndex, images.length]); const current = images[currentIndex]; const hasMultiple = images.length > 1; const [scale, setScale] = useState(1); const [dialogOpen, setDialogOpen] = useState(false); const [loadError, setLoadError] = useState(false); const [imgDecoded, setImgDecoded] = useState(false); const containerRef = useRef(null); const controlsRef = useRef(null); // Idle-fading chrome (lightbox only). Pointer movement over the canvas keeps // the controls visible; resting for CHROME_IDLE_MS fades them out. const { active: chromeActive, onActivity, setPinned } = useIdleChrome(isLightbox); const labels = useMemo(() => ({ noImage: t('tools.image.noImage'), failedToLoad: t('tools.image.failedToLoad'), loading: t('ui.form.loading'), prev: t('tools.gallery.previous'), next: t('tools.gallery.next'), close: t('tools.gallery.close'), }), [t]); const { src, lqip, isFullyLoaded, useProgressiveLoading, error, hasContent, } = useImageLoading({ content: current?.content ?? '', mimeType: current?.file.mimeType, src: current?.src, }); // Reset per-source load flags whenever the source changes useEffect(() => { setLoadError(false); setImgDecoded(false); }, [src]); const { transform, rotate, flipH, flipV, transformStyle } = useImageTransform({ resetKey: current?.file.path ?? '', }); const handleZoomPreset = useCallback((value: number | 'fit') => { const controls = controlsRef.current; if (!controls) return; if (value === 'fit') { controls.resetTransform(); return; } // Anchor preset zoom to the canvas center instead of the top-left origin const el = controls.instance.wrapperComponent; if (el) { const cx = el.offsetWidth / 2; const cy = el.offsetHeight / 2; const x = cx - cx * value; const y = cy - cy * value; controls.setTransform(x, y, value); } else { controls.setTransform(0, 0, value); } }, []); const handleExpand = useCallback(() => setDialogOpen(true), []); // Reset zoom/pan to fit whenever the gallery image changes — a fresh image // should never inherit the previous one's pan offset. const goTo = useCallback((next: number) => { setCurrentIndex(next); controlsRef.current?.resetTransform(); }, []); const prev = useCallback(() => goTo((currentIndex - 1 + images.length) % images.length), [goTo, currentIndex, images.length] ); const next = useCallback(() => goTo((currentIndex + 1) % images.length), [goTo, currentIndex, images.length] ); // Pan the image by a fixed offset (arrow keys) const panBy = useCallback((dx: number, dy: number) => { const controls = controlsRef.current; if (!controls) return false; const { positionX, positionY, scale: s } = controls.instance.transformState; // Only pan when the image is zoomed in — otherwise it cannot move if (s <= 1) return false; controls.setTransform(positionX + dx, positionY + dy, s); return true; }, []); // Declarative autoFocus: focus the container once on mount. Pair with // `key={src}` upstream for per-source focus reset. useEffect(() => { if (!autoFocus) return; containerRef.current?.focus({ preventScroll: true }); }, [autoFocus]); // Keyboard: zoom / rotate / pan (only when container focused) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!containerRef.current?.contains(document.activeElement) && document.activeElement !== containerRef.current) return; const controls = controlsRef.current; if (!controls) return; switch (e.key) { case '+': case '=': e.preventDefault(); controls.zoomIn(); break; case '-': case '_': e.preventDefault(); controls.zoomOut(); break; case '0': e.preventDefault(); controls.resetTransform(); break; case 'r': case 'R': // Rotate is an editing affordance — embedded mode only. if (!isLightbox && !e.metaKey && !e.ctrlKey) { e.preventDefault(); rotate(); } break; case 'Escape': if (isLightbox && onRequestClose) { e.preventDefault(); onRequestClose(); } break; // Arrow keys pan only when zoomed in. While at fit scale they // fall through to gallery navigation (handled by useHotkey). case 'ArrowUp': if (panBy(0, KEYBOARD_PAN_STEP)) e.preventDefault(); break; case 'ArrowDown': if (panBy(0, -KEYBOARD_PAN_STEP)) e.preventDefault(); break; case 'ArrowLeft': if (panBy(KEYBOARD_PAN_STEP, 0)) e.preventDefault(); break; case 'ArrowRight': if (panBy(-KEYBOARD_PAN_STEP, 0)) e.preventDefault(); break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [rotate, panBy, isLightbox, onRequestClose]); // Keyboard: gallery navigation (global, only while not zoomed in so it // never competes with arrow-key panning) useHotkey('ArrowLeft', prev, { enabled: hasMultiple && scale <= 1, preventDefault: true, }); useHotkey('ArrowRight', next, { enabled: hasMultiple && scale <= 1, preventDefault: true, }); if (!current) { return (

{labels.noImage}

); } if (error || loadError) { return (
{error || labels.failedToLoad}
); } if (!hasContent) { return (

{labels.noImage}

); } return (
{/* Dimensions badge — embedded only (lightbox keeps chrome minimal). */} {!isLightbox && src && } {useProgressiveLoading && !isFullyLoaded && (
{labels.loading}
)} {/* Spinner while a non-progressive image is still decoding */} {!useProgressiveLoading && !imgDecoded && !loadError && (
{labels.loading}
)} { controlsRef.current = ref; // Avoid a re-render on every pan frame — only when zoom changes setScale((prev) => (prev === state.scale ? prev : state.scale)); }} onInit={(ref) => { controlsRef.current = ref; }} > {/* Embedded editing toolbar (rotate/flip/expand) — not in the lightbox. */} {!isLightbox && ( )} {/* Fill the TransformComponent content box so the children's `max-w-full / max-h-full` resolve against the actual viewport instead of the image's natural box. Without `w-full h-full` this wrapper shrink-fits the image, which collapses the max-* constraints and renders the image at intrinsic size — visible as cropping / half-height in tall containers. */}
{useProgressiveLoading && lqip && !isFullyLoaded && ( )} {src && ( {current.file.name} setImgDecoded(true)} onError={() => setLoadError(true)} /> )}
{/* Lightbox: restrained macOS-grade chrome that idle-fades. */} {isLightbox ? ( onRequestClose?.()} onPinChange={setPinned} labels={{ prev: labels.prev, next: labels.next, close: labels.close }} /> ) : ( hasMultiple && ( // Embedded gallery nav (checkerboard canvas). <> {labels.prev} {labels.next}
{currentIndex + 1} / {images.length}
) )} {/* Embedded expand-to-fullscreen dialog (uses the lightbox presentation). */} {!inDialog && !lightbox && ( {current.file.name} {dialogOpen && ( setDialogOpen(false)} /> )} )}
); }