176 lines
4.7 KiB
TypeScript
176 lines
4.7 KiB
TypeScript
'use client';
|
|
|
|
import { X } from 'lucide-react';
|
|
import Image from 'next/image';
|
|
import React, { useEffect, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
interface ImageViewerProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
imageUrl: string;
|
|
alt?: string;
|
|
}
|
|
|
|
const ImageViewer: React.FC<ImageViewerProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
imageUrl,
|
|
alt = '图片',
|
|
}) => {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const [isAnimating, setIsAnimating] = useState(false);
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
// 确保组件在客户端挂载后才渲染 Portal
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
// 控制动画状态
|
|
useEffect(() => {
|
|
let animationId: number;
|
|
let timer: NodeJS.Timeout;
|
|
|
|
if (isOpen) {
|
|
setIsVisible(true);
|
|
animationId = requestAnimationFrame(() => {
|
|
animationId = requestAnimationFrame(() => {
|
|
setIsAnimating(true);
|
|
});
|
|
});
|
|
} else {
|
|
setIsAnimating(false);
|
|
timer = setTimeout(() => {
|
|
setIsVisible(false);
|
|
}, 200);
|
|
}
|
|
|
|
return () => {
|
|
if (animationId) {
|
|
cancelAnimationFrame(animationId);
|
|
}
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
};
|
|
}, [isOpen]);
|
|
|
|
// 阻止背景滚动
|
|
useEffect(() => {
|
|
if (isVisible) {
|
|
const scrollY = window.scrollY;
|
|
const scrollX = window.scrollX;
|
|
const body = document.body;
|
|
const html = document.documentElement;
|
|
|
|
const scrollBarWidth = window.innerWidth - html.clientWidth;
|
|
|
|
const originalBodyStyle = {
|
|
position: body.style.position,
|
|
top: body.style.top,
|
|
left: body.style.left,
|
|
right: body.style.right,
|
|
width: body.style.width,
|
|
paddingRight: body.style.paddingRight,
|
|
overflow: body.style.overflow,
|
|
};
|
|
|
|
body.style.position = 'fixed';
|
|
body.style.top = `-${scrollY}px`;
|
|
body.style.left = `-${scrollX}px`;
|
|
body.style.right = '0';
|
|
body.style.width = '100%';
|
|
body.style.overflow = 'hidden';
|
|
body.style.paddingRight = `${scrollBarWidth}px`;
|
|
|
|
return () => {
|
|
body.style.position = originalBodyStyle.position;
|
|
body.style.top = originalBodyStyle.top;
|
|
body.style.left = originalBodyStyle.left;
|
|
body.style.right = originalBodyStyle.right;
|
|
body.style.width = originalBodyStyle.width;
|
|
body.style.paddingRight = originalBodyStyle.paddingRight;
|
|
body.style.overflow = originalBodyStyle.overflow;
|
|
|
|
requestAnimationFrame(() => {
|
|
window.scrollTo(scrollX, scrollY);
|
|
});
|
|
};
|
|
}
|
|
}, [isVisible]);
|
|
|
|
// ESC键关闭
|
|
useEffect(() => {
|
|
const handleEsc = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
if (isVisible) {
|
|
document.addEventListener('keydown', handleEsc);
|
|
return () => document.removeEventListener('keydown', handleEsc);
|
|
}
|
|
}, [isVisible, onClose]);
|
|
|
|
if (!isVisible || !mounted) return null;
|
|
|
|
const content = (
|
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-0 sm:p-4">
|
|
{/* 背景遮罩 */}
|
|
<div
|
|
className={`absolute inset-0 bg-black/80 transition-opacity duration-200 ease-out ${
|
|
isAnimating ? 'opacity-100' : 'opacity-0'
|
|
}`}
|
|
onClick={onClose}
|
|
style={{
|
|
backdropFilter: 'blur(4px)',
|
|
willChange: 'opacity',
|
|
}}
|
|
/>
|
|
|
|
{/* 关闭按钮 */}
|
|
<button
|
|
onClick={onClose}
|
|
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors duration-150"
|
|
aria-label="关闭"
|
|
>
|
|
<X size={24} className="text-white" />
|
|
</button>
|
|
|
|
{/* 图片容器 */}
|
|
<div
|
|
className="relative max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] transition-all duration-200 ease-out"
|
|
style={{
|
|
willChange: 'transform, opacity',
|
|
backfaceVisibility: 'hidden',
|
|
transform: isAnimating ? 'scale(1) translateZ(0)' : 'scale(0.95) translateZ(0)',
|
|
opacity: isAnimating ? 1 : 0,
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="relative w-full h-full">
|
|
<Image
|
|
src={imageUrl}
|
|
alt={alt}
|
|
width={1200}
|
|
height={1800}
|
|
className="object-contain max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] w-auto h-auto"
|
|
style={{
|
|
maxWidth: '100vw',
|
|
maxHeight: '100vh',
|
|
}}
|
|
priority
|
|
quality={100}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return createPortal(content, document.body);
|
|
};
|
|
|
|
export default ImageViewer;
|