import React, { CSSProperties, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; import { clamp } from '../utils/math'; type PreviewNavigator = { url: string; alt?: string; canGoPrev?: boolean; canGoNext?: boolean; goPrev?: () => void; goNext?: () => void; }; interface PreviewZoomOverlayProps { open?: boolean; display?: PreviewNavigator | null; onClose?: () => void; } type NaturalSize = { width: number | null; height: number | null }; type FocusPoint = { xRatio: number; yRatio: number } | null; const noop = () => {}; const PreviewZoomOverlay: React.FC = ({ open = false, display = null, onClose = noop, }) => { const portalTarget = typeof document !== 'undefined' ? document.body : null; 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 = window.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 (typeof document === 'undefined') { return; } if (!open) { previouslyFocusedRef.current?.focus?.(); previouslyFocusedRef.current = null; return; } const active = document.activeElement; previouslyFocusedRef.current = active instanceof HTMLElement ? active : null; }, [open]); const activeDisplay = open && display?.url ? display : displaySnapshot; useEffect(() => { if (!activeDisplay?.url) { return; } const scrollEl = scrollRef.current; if (scrollEl) { scrollEl.scrollLeft = 0; scrollEl.scrollTop = 0; } focusRef.current = null; }, [activeDisplay?.url]); useEffect(() => { if (!renderBackdrop || !activeDisplay?.url) { return undefined; } const frame = requestAnimationFrame(() => { scrollRef.current?.focus?.({ preventScroll: true }); }); return () => cancelAnimationFrame(frame); }, [renderBackdrop, activeDisplay?.url]); const handleKeyDown = (event: React.KeyboardEvent) => { 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: number, clientY: number) => { 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 handleImageClick = (event: React.MouseEvent) => { event.stopPropagation(); toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0); }; if (!renderBackdrop || !activeDisplay?.url || !portalTarget) { return null; } const effectiveDisplay = activeDisplay; const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext); const stageClassName = 'preview-zoom__stage'; 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: CSSProperties = 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()} > {effectiveDisplay.alt { setNaturalSize({ width: event.currentTarget.naturalWidth || null, height: event.currentTarget.naturalHeight || null, }); }} onClick={handleImageClick} style={imageStyle} />
{navVisible ? (
) : null}
), portalTarget, ); }; export default PreviewZoomOverlay;