import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; import usePointerTap from '../ui/usePointerTap'; const noop = () => {}; const clamp = (value, min, max) => { if (value < min) return min; if (value > max) return max; return value; }; const ensureDocumentRoot = () => { if (typeof document === 'undefined') { return null; } return document.body; }; const CLICK_DELAY_MS = 240; const PreviewZoomOverlay = ({ open = false, display = null, onClose = noop, }) => { const portalTarget = ensureDocumentRoot(); const [isNativeScale, setIsNativeScale] = useState(false); const [naturalSize, setNaturalSize] = useState({ width: null, height: null }); const [renderBackdrop, setRenderBackdrop] = useState(false); const [isBackdropVisible, setBackdropVisible] = useState(false); const [displaySnapshot, setDisplaySnapshot] = useState(null); const scrollRef = useRef(null); const imageRef = useRef(null); const focusRef = useRef(null); const previouslyFocusedRef = useRef(null); const visibilityTimerRef = useRef(null); const displayTimerRef = useRef(null); useEffect(() => { if (display?.url) { setDisplaySnapshot(display); } }, [display]); useEffect(() => { if (visibilityTimerRef.current) { clearTimeout(visibilityTimerRef.current); visibilityTimerRef.current = null; } if (displayTimerRef.current) { cancelAnimationFrame(displayTimerRef.current); displayTimerRef.current = null; } if (open && display?.url) { setRenderBackdrop(true); displayTimerRef.current = requestAnimationFrame(() => { displayTimerRef.current = requestAnimationFrame(() => { setBackdropVisible(true); }); }); return () => { if (displayTimerRef.current) { cancelAnimationFrame(displayTimerRef.current); displayTimerRef.current = null; } }; } setBackdropVisible(false); visibilityTimerRef.current = setTimeout(() => { setRenderBackdrop(false); }, 260); return () => { if (visibilityTimerRef.current) { clearTimeout(visibilityTimerRef.current); visibilityTimerRef.current = null; } }; }, [open, display?.url]); useEffect(() => () => { if (visibilityTimerRef.current) { clearTimeout(visibilityTimerRef.current); } if (displayTimerRef.current) { cancelAnimationFrame(displayTimerRef.current); } }, []); useEffect(() => { setIsNativeScale(false); setNaturalSize({ width: null, height: null }); focusRef.current = null; const scrollEl = scrollRef.current; if (scrollEl) { scrollEl.scrollLeft = 0; scrollEl.scrollTop = 0; } }, [open]); useEffect(() => { if (!open || !isNativeScale) { return; } const scrollEl = scrollRef.current; const imageEl = imageRef.current; if (!scrollEl || !imageEl) { return; } const imageWidth = imageEl.naturalWidth || imageEl.clientWidth; const imageHeight = imageEl.naturalHeight || imageEl.clientHeight; if (!(imageWidth > 0 && imageHeight > 0)) { return; } const target = focusRef.current || { xRatio: 0.5, yRatio: 0.5 }; const maxScrollLeft = Math.max(0, imageWidth - scrollEl.clientWidth); const maxScrollTop = Math.max(0, imageHeight - scrollEl.clientHeight); const desiredLeft = target.xRatio * imageWidth - scrollEl.clientWidth / 2; const desiredTop = target.yRatio * imageHeight - scrollEl.clientHeight / 2; scrollEl.scrollLeft = clamp(desiredLeft, 0, maxScrollLeft); scrollEl.scrollTop = clamp(desiredTop, 0, maxScrollTop); }, [open, isNativeScale, naturalSize.width, naturalSize.height]); useEffect(() => { if (!open) { if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') { previouslyFocusedRef.current.focus(); } previouslyFocusedRef.current = null; return; } if (typeof document !== 'undefined') { const active = document.activeElement; if (active && typeof active.focus === 'function') { previouslyFocusedRef.current = active; } else { previouslyFocusedRef.current = null; } } }, [open]); const activeDisplay = open && display?.url ? display : displaySnapshot; useEffect(() => { if (!renderBackdrop || !activeDisplay?.url) { return undefined; } const frame = requestAnimationFrame(() => { const scrollEl = scrollRef.current; if (scrollEl && typeof scrollEl.focus === 'function') { scrollEl.focus({ preventScroll: true }); } }); return () => cancelAnimationFrame(frame); }, [renderBackdrop, activeDisplay?.url]); const handleKeyDown = (event) => { event.stopPropagation(); if (!open) { return; } const key = event.key; if (key === ' ' || key === 'Space' || key === 'Spacebar') { const target = event.target; if (target instanceof HTMLElement) { const tag = target.tagName ? target.tagName.toLowerCase() : ''; if ( target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select' ) { return; } } event.preventDefault(); onClose(); return; } if (key === 'Escape') { event.preventDefault(); onClose(); return; } if (key === 'ArrowLeft') { if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) { event.preventDefault(); activeDisplay.goPrev(); } return; } if (key === 'ArrowRight') { if (activeDisplay?.canGoNext && activeDisplay?.goNext) { event.preventDefault(); activeDisplay.goNext(); } } }; const toggleZoomAtPoint = (clientX, clientY) => { const img = imageRef.current; setIsNativeScale((current) => { if (!current && img) { const rect = img.getBoundingClientRect(); const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5; const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5; focusRef.current = { xRatio: clamp(xRatio, 0, 1), yRatio: clamp(yRatio, 0, 1), }; } else { focusRef.current = null; } return !current; }); }; const pointerTapHandler = usePointerTap({ delay: CLICK_DELAY_MS, onSingle: () => { onClose(); }, onDouble: ({ clientX, clientY }) => { toggleZoomAtPoint(clientX, clientY); }, }); const handleImagePointerDown = (event) => { event.stopPropagation(); pointerTapHandler(event); }; if (!renderBackdrop || !activeDisplay?.url || !portalTarget) { return null; } const effectiveDisplay = activeDisplay; const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext); const stageClassName = [ 'preview-zoom__stage', ] .filter(Boolean) .join(' '); const containerClassName = [ 'preview-zoom__scroll', isNativeScale ? 'preview-zoom__scroll--native' : '', ] .filter(Boolean) .join(' '); const backdropClassName = [ 'preview-zoom-backdrop', isBackdropVisible ? 'preview-zoom-backdrop--visible' : '', ] .filter(Boolean) .join(' '); const imageStyle = isNativeScale ? { cursor: 'zoom-out', width: naturalSize.width ? `${naturalSize.width}px` : 'auto', height: naturalSize.height ? `${naturalSize.height}px` : 'auto', maxWidth: 'none', maxHeight: 'none', touchAction: 'manipulation', } : { cursor: 'zoom-in', maxWidth: '95vw', maxHeight: '95vh', touchAction: 'manipulation', }; return createPortal( (
event.stopPropagation()} onKeyDown={handleKeyDown} >
{effectiveDisplay.alt { setNaturalSize({ width: event.currentTarget.naturalWidth || null, height: event.currentTarget.naturalHeight || null, }); }} onPointerDown={handleImagePointerDown} style={imageStyle} />
{navVisible ? (
) : null}
), portalTarget, ); }; export default PreviewZoomOverlay;