import { useEffect, useRef, useState, useCallback } from 'react'; import cs from 'classnames'; import { createPortal } from 'react-dom'; import classes from './ImageMessage.module.scss'; interface ImagePreviewProps { open: boolean; src: string; alt?: string; onClose: () => void; } const MIN_SCALE = 0.5; const MAX_SCALE = 3; function clampScale(value: number) { return Math.min(MAX_SCALE, Math.max(MIN_SCALE, value)); } const ImagePreview = ({ open, src, alt, onClose }: ImagePreviewProps) => { const [scale, setScale] = useState(1); const [translate, setTranslate] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [isError, setIsError] = useState(false); const lastPointer = useRef({ x: 0, y: 0 }); const pinchStartDistance = useRef(null); const pinchStartScale = useRef(1); const imgRef = useRef(null); const prevOverflow = useRef(null); // Reset transform state const resetTransform = useCallback(() => { setScale(1); setTranslate({ x: 0, y: 0 }); setIsDragging(false); }, []); // Close preview and reset state const handleClose = useCallback(() => { resetTransform(); setIsError(false); onClose(); }, [onClose, resetTransform]); // Handle overlay click (close preview, prevent bubbling to parent) const handleOverlayClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); handleClose(); }, [handleClose]); // Lock body scroll when preview is open, handle ESC key useEffect(() => { if (!open) { return undefined; } // Lock body scroll prevOverflow.current = document.body.style.overflow; document.body.style.overflow = 'hidden'; // Handle ESC key const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { handleClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); // Restore body scroll if (prevOverflow.current !== null) { document.body.style.overflow = prevOverflow.current; prevOverflow.current = null; } }; }, [open, handleClose]); // Reset state when preview is closed useEffect(() => { if (!open) { resetTransform(); setIsError(false); } }, [open, resetTransform]); // Image interaction handlers const handleImageError = useCallback(() => { setIsError(true); }, []); const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? -0.2 : 0.2; setScale(prev => clampScale(prev + delta)); }, []); const handleDoubleClick = useCallback(() => { setScale((prev) => { const next = prev === 1 ? 2 : 1; if (next === 1) { setTranslate({ x: 0, y: 0 }); } return next; }); }, []); // Drag handlers (pointer events) const handlePointerDown = useCallback((e: React.PointerEvent) => { setIsDragging(true); lastPointer.current = { x: e.clientX, y: e.clientY }; e.currentTarget.setPointerCapture(e.pointerId); }, []); const handlePointerMove = useCallback((e: React.PointerEvent) => { if (!isDragging) { return; } const dx = e.clientX - lastPointer.current.x; const dy = e.clientY - lastPointer.current.y; setTranslate(prev => ({ x: prev.x + dx, y: prev.y + dy })); lastPointer.current = { x: e.clientX, y: e.clientY }; }, [isDragging]); const handlePointerUp = useCallback((e: React.PointerEvent) => { setIsDragging(false); e.currentTarget.releasePointerCapture(e.pointerId); }, []); // Pinch zoom handlers (touch events for H5) const getTouchDistance = (touches: TouchList) => { if (touches.length < 2) { return null; } const [t1, t2] = [touches[0], touches[1]]; const dx = t1.clientX - t2.clientX; const dy = t1.clientY - t2.clientY; return Math.hypot(dx, dy); }; const handleTouchStart = useCallback((e: React.TouchEvent) => { if (e.touches.length === 2) { pinchStartDistance.current = getTouchDistance(e.touches); pinchStartScale.current = scale; } }, [scale]); const handleTouchMove = useCallback((e: React.TouchEvent) => { if (e.touches.length === 2 && pinchStartDistance.current) { e.preventDefault(); const currentDistance = getTouchDistance(e.touches); if (!currentDistance) { return; } const factor = currentDistance / pinchStartDistance.current; setScale(clampScale(pinchStartScale.current * factor)); } }, []); const handleTouchEnd = useCallback(() => { pinchStartDistance.current = null; }, []); if (!open) { return null; } return createPortal( // Overlay container: click to close, stop propagation to prevent triggering parent ImageMessage
{/* Gray background mask */}
{/* Close button: stop propagation to prevent triggering overlay click */}