mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-12 19:41:03 +08:00
refactor(ImageViewer): optimize image loading and zoom functionality
This commit is contained in:
@@ -83,30 +83,33 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1003;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.image-transform-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.transform-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.transform-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.viewer-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.viewer-img.dragging {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
@@ -338,7 +341,6 @@
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* 图片加载动画样式 */
|
||||
.image-loading-spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -357,7 +359,6 @@
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Spin组件自定义样式 */
|
||||
.image-loading-spinner .ant-spin {
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
RotateRightOutlined, HeartOutlined, HeartFilled, DownloadOutlined,
|
||||
ShareAltOutlined, FolderAddOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
|
||||
import type { PictureResponse, AlbumResponse } from '../../api/types';
|
||||
import { getAlbums, addPicturesToAlbum, favoritePicture, unfavoritePicture } from '../../api';
|
||||
import ImageInfo from './ImageInfo';
|
||||
@@ -25,7 +24,6 @@ interface ImageViewerProps {
|
||||
onShare?: (image: PictureResponse) => void;
|
||||
}
|
||||
|
||||
// 添加图片缓存对象
|
||||
interface ImageCache {
|
||||
[key: string]: {
|
||||
loaded: boolean;
|
||||
@@ -33,6 +31,17 @@ interface ImageCache {
|
||||
}
|
||||
}
|
||||
|
||||
interface ZoomPanState {
|
||||
scale: number;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
isDragging: boolean;
|
||||
dragStartX: number;
|
||||
dragStartY: number;
|
||||
lastPositionX: number;
|
||||
lastPositionY: number;
|
||||
}
|
||||
|
||||
const ImageViewer: React.FC<ImageViewerProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
@@ -53,62 +62,125 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
|
||||
const [localImages, setLocalImages] = useState<PictureResponse[]>(images);
|
||||
const [shareDialogVisible, setShareDialogVisible] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [currentLoading, setCurrentLoading] = useState(false);
|
||||
|
||||
// 修改:使用 useRef 存储图片缓存,避免重复加载
|
||||
const [zoomPanState, setZoomPanState] = useState<ZoomPanState>({
|
||||
scale: 1,
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
isDragging: false,
|
||||
dragStartX: 0,
|
||||
dragStartY: 0,
|
||||
lastPositionX: 0,
|
||||
lastPositionY: 0,
|
||||
});
|
||||
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const imageCache = useRef<ImageCache>({});
|
||||
|
||||
// 替换原来的 cacheKey
|
||||
const sessionKey = useRef<string>(Date.now().toString());
|
||||
|
||||
const currentImage = localImages[currentIndex];
|
||||
|
||||
// 预加载图片的范围
|
||||
const preloadRange = 2; // 当前图片前后各预加载2张
|
||||
const preloadRange = 2;
|
||||
|
||||
const MIN_SCALE = 0.1;
|
||||
const MAX_SCALE = 8;
|
||||
const ZOOM_FACTOR = 0.2;
|
||||
|
||||
const resetViewerState = useCallback(() => {
|
||||
setRotation(0);
|
||||
setIsInfoDrawerOpen(false);
|
||||
setImageLoaded(false);
|
||||
setZoomPanState({
|
||||
scale: 1,
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
isDragging: false,
|
||||
dragStartX: 0,
|
||||
dragStartY: 0,
|
||||
lastPositionX: 0,
|
||||
lastPositionY: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 优化图片加载函数
|
||||
// 当前加载中的图片URL追踪
|
||||
const currentLoadingUrl = useRef<string | null>(null);
|
||||
|
||||
// 预渲染图片容器
|
||||
const preloadedImagesRef = useRef<{[key: string]: HTMLImageElement}>({});
|
||||
|
||||
// 图片过渡状态
|
||||
const [fadeTransition, setFadeTransition] = useState(false);
|
||||
const [, setActiveImage] = useState<string | null>(null);
|
||||
|
||||
const loadImage = useCallback((imageUrl: string): Promise<HTMLImageElement> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 检查是否已经缓存
|
||||
// 检查缓存
|
||||
if (imageCache.current[imageUrl]?.loaded) {
|
||||
setImageLoaded(true);
|
||||
if (currentImage && imageUrl === currentImage.path) {
|
||||
setImageLoaded(true);
|
||||
setActiveImage(imageUrl);
|
||||
}
|
||||
return resolve(imageCache.current[imageUrl].img);
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// 更新缓存
|
||||
imageCache.current[imageUrl] = {
|
||||
loaded: true,
|
||||
img
|
||||
};
|
||||
|
||||
preloadedImagesRef.current[imageUrl] = img;
|
||||
|
||||
if (imageUrl === currentLoadingUrl.current) {
|
||||
setImageLoaded(true);
|
||||
setCurrentLoading(false);
|
||||
setActiveImage(imageUrl);
|
||||
}
|
||||
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (imageUrl === currentLoadingUrl.current) {
|
||||
setCurrentLoading(false);
|
||||
}
|
||||
reject(new Error(`Failed to load image: ${imageUrl}`));
|
||||
};
|
||||
|
||||
// 使用相对持久的缓存键,而不是每次都更新
|
||||
img.src = `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}_s=${sessionKey.current}`;
|
||||
});
|
||||
}, []);
|
||||
}, [currentImage]);
|
||||
|
||||
// 修改:当图片索引变化时只设置加载状态,不重置缓存键
|
||||
// 图片切换逻辑优化
|
||||
useEffect(() => {
|
||||
setImageLoaded(false);
|
||||
setCurrentLoading(true);
|
||||
setFadeTransition(true);
|
||||
|
||||
// 利用缓存快速显示
|
||||
if (currentImage && imageCache.current[currentImage.path]?.loaded) {
|
||||
setActiveImage(currentImage.path);
|
||||
setImageLoaded(true);
|
||||
setCurrentLoading(false);
|
||||
|
||||
setTimeout(() => setFadeTransition(false), 100);
|
||||
}
|
||||
|
||||
// 重置缩放状态
|
||||
setZoomPanState(prev => ({
|
||||
...prev,
|
||||
scale: 1,
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
isDragging: false
|
||||
}));
|
||||
}, [currentIndex]);
|
||||
|
||||
// 修改:查看器可见性改变时的处理逻辑
|
||||
// 可见性变化处理
|
||||
useEffect(() => {
|
||||
if (visible && !wasVisible.current) {
|
||||
resetViewerState();
|
||||
|
||||
// 生成会话唯一的缓存键,而不是每次都更新
|
||||
if (!sessionKey.current) {
|
||||
sessionKey.current = Date.now().toString();
|
||||
}
|
||||
@@ -116,37 +188,45 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
|
||||
wasVisible.current = visible;
|
||||
}, [visible, resetViewerState]);
|
||||
|
||||
// 初始索引处理
|
||||
useEffect(() => {
|
||||
if (visible && initialIndex >= 0 && initialIndex < images.length) {
|
||||
setCurrentIndex(initialIndex);
|
||||
}
|
||||
}, [visible, initialIndex, images.length]);
|
||||
|
||||
// 修改:加载当前图片并预加载相邻图片
|
||||
// 图片加载逻辑
|
||||
useEffect(() => {
|
||||
if (!currentImage || !visible) return;
|
||||
|
||||
// 加载当前图片
|
||||
const imagePath = currentImage.path;
|
||||
loadImage(imagePath)
|
||||
.then(() => setImageLoaded(true))
|
||||
currentLoadingUrl.current = currentImage.path;
|
||||
setCurrentLoading(true);
|
||||
|
||||
loadImage(currentImage.path)
|
||||
.then(() => {
|
||||
if (currentLoadingUrl.current === currentImage.path) {
|
||||
setImageLoaded(true);
|
||||
setCurrentLoading(false);
|
||||
setActiveImage(currentImage.path);
|
||||
|
||||
setTimeout(() => setFadeTransition(false), 100);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to load image:', error);
|
||||
message.error('图片加载失败,请重试');
|
||||
setCurrentLoading(false);
|
||||
});
|
||||
|
||||
// 预加载相邻图片
|
||||
if (localImages.length > 1) {
|
||||
// 使用 setTimeout 延迟预加载,优先加载当前图片
|
||||
setTimeout(() => {
|
||||
for (let i = 1; i <= preloadRange; i++) {
|
||||
// 预加载后面的图片
|
||||
const nextIndex = currentIndex + i;
|
||||
if (nextIndex < localImages.length) {
|
||||
loadImage(localImages[nextIndex].path).catch(() => {});
|
||||
}
|
||||
|
||||
// 预加载前面的图片
|
||||
const prevIndex = currentIndex - i;
|
||||
if (prevIndex >= 0) {
|
||||
loadImage(localImages[prevIndex].path).catch(() => {});
|
||||
@@ -269,11 +349,146 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
|
||||
onShare ? onShare(currentImage) : setShareDialogVisible(true);
|
||||
}, [currentImage, onShare]);
|
||||
|
||||
const zoomIn = useCallback((factor = ZOOM_FACTOR) => {
|
||||
setZoomPanState(prev => ({
|
||||
...prev,
|
||||
scale: Math.min(MAX_SCALE, prev.scale * (1 + factor))
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback((factor = ZOOM_FACTOR) => {
|
||||
setZoomPanState(prev => ({
|
||||
...prev,
|
||||
scale: Math.max(MIN_SCALE, prev.scale / (1 + factor))
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const resetTransform = useCallback(() => {
|
||||
setZoomPanState({
|
||||
scale: 1,
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
isDragging: false,
|
||||
dragStartX: 0,
|
||||
dragStartY: 0,
|
||||
lastPositionX: 0,
|
||||
lastPositionY: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY || e.deltaX;
|
||||
const scaleFactor = delta > 0 ? 0.9 : 1.1;
|
||||
|
||||
setZoomPanState(prev => {
|
||||
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * scaleFactor));
|
||||
|
||||
const rect = imageContainerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return { ...prev, scale: newScale };
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const containerCenterX = rect.width / 2;
|
||||
const containerCenterY = rect.height / 2;
|
||||
const dx = (mouseX - containerCenterX - prev.positionX) * (scaleFactor - 1);
|
||||
const dy = (mouseY - containerCenterY - prev.positionY) * (scaleFactor - 1);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
scale: newScale,
|
||||
positionX: prev.positionX - dx,
|
||||
positionY: prev.positionY - dy,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setZoomPanState(prev => ({
|
||||
...prev,
|
||||
isDragging: true,
|
||||
dragStartX: e.clientX,
|
||||
dragStartY: e.clientY,
|
||||
lastPositionX: prev.positionX,
|
||||
lastPositionY: prev.positionY
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (e.touches.length === 1) {
|
||||
const touch = e.touches[0];
|
||||
setZoomPanState(prev => ({
|
||||
...prev,
|
||||
isDragging: true,
|
||||
dragStartX: touch.clientX,
|
||||
dragStartY: touch.clientY,
|
||||
lastPositionX: prev.positionX,
|
||||
lastPositionY: prev.positionY
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (zoomPanState.isDragging) {
|
||||
const dx = e.clientX - zoomPanState.dragStartX;
|
||||
const dy = e.clientY - zoomPanState.dragStartY;
|
||||
|
||||
setZoomPanState(prev => ({
|
||||
...prev,
|
||||
positionX: prev.lastPositionX + dx,
|
||||
positionY: prev.lastPositionY + dy
|
||||
}));
|
||||
}
|
||||
}, [zoomPanState.isDragging, zoomPanState.dragStartX, zoomPanState.dragStartY, zoomPanState.lastPositionX, zoomPanState.lastPositionY]);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (zoomPanState.isDragging && e.touches.length === 1) {
|
||||
const touch = e.touches[0];
|
||||
const dx = touch.clientX - zoomPanState.dragStartX;
|
||||
const dy = touch.clientY - zoomPanState.dragStartY;
|
||||
|
||||
setZoomPanState(prev => ({
|
||||
...prev,
|
||||
positionX: prev.lastPositionX + dx,
|
||||
positionY: prev.lastPositionY + dy
|
||||
}));
|
||||
}
|
||||
}, [zoomPanState.isDragging, zoomPanState.dragStartX, zoomPanState.dragStartY, zoomPanState.lastPositionX, zoomPanState.lastPositionY]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setZoomPanState(prev => ({ ...prev, isDragging: false }));
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setZoomPanState(prev => ({ ...prev, isDragging: false }));
|
||||
}, []);
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
resetTransform();
|
||||
}, [resetTransform]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
window.addEventListener('mouseleave', handleMouseUp);
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
window.addEventListener('touchcancel', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
window.removeEventListener('mouseleave', handleMouseUp);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
window.removeEventListener('touchcancel', handleTouchEnd);
|
||||
};
|
||||
}
|
||||
}, [visible, handleMouseUp, handleTouchEnd]);
|
||||
|
||||
// 渲染优化:减少重绘和提高性能
|
||||
if (images.length === 0 || !currentImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 修改:更新图片URL,使用会话缓存键
|
||||
const getImageUrl = (path: string) => {
|
||||
return `${path}${path.includes('?') ? '&' : '?'}_s=${sessionKey.current}`;
|
||||
};
|
||||
@@ -286,140 +501,134 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
|
||||
<div className="viewer-overlay" onClick={onClose}></div>
|
||||
|
||||
<div className="viewer-content">
|
||||
<div className="image-container">
|
||||
<TransformWrapper
|
||||
initialScale={1}
|
||||
initialPositionX={0}
|
||||
initialPositionY={0}
|
||||
centerOnInit={true}
|
||||
minScale={0.1}
|
||||
maxScale={8}
|
||||
wheel={{ step: 0.2 }}
|
||||
doubleClick={{ mode: 'reset' }}
|
||||
panning={{ disabled: false }}
|
||||
alignmentAnimation={{ disabled: true }}
|
||||
velocityAnimation={{ disabled: false }}
|
||||
>
|
||||
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||
<>
|
||||
<TransformComponent
|
||||
wrapperClass="transform-wrapper"
|
||||
contentClass="transform-content"
|
||||
>
|
||||
{currentImage && (
|
||||
<img
|
||||
src={getImageUrl(currentImage.path)}
|
||||
alt={currentImage.name}
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
opacity: imageLoaded ? 1 : 0.3,
|
||||
transition: 'opacity 0.3s ease'
|
||||
}}
|
||||
className="viewer-img"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</TransformComponent>
|
||||
|
||||
{!imageLoaded && (
|
||||
<div className="image-loading-spinner">
|
||||
<Spin size="large" tip={<span className="loading-text">图片加载中...</span>} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="zoom-controls">
|
||||
<Space>
|
||||
<Button icon={<ExpandOutlined />} onClick={(_e) => resetTransform()} />
|
||||
<Button icon={<ZoomOutOutlined />} onClick={() => zoomOut(0.5)} />
|
||||
<Button icon={<ZoomInOutlined />} onClick={() => zoomIn(0.5)} />
|
||||
<Button icon={<RotateLeftOutlined />} onClick={() => setRotation(prev => prev - 90)} />
|
||||
<Button icon={<RotateRightOutlined />} onClick={() => setRotation(prev => prev + 90)} />
|
||||
</Space>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
|
||||
{currentIndex > 0 && (
|
||||
<Button
|
||||
className="nav-button prev-button"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={handlePrevious}
|
||||
shape="circle"
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentIndex < images.length - 1 && (
|
||||
<Button
|
||||
className="nav-button next-button"
|
||||
icon={<RightOutlined />}
|
||||
onClick={handleNext}
|
||||
shape="circle"
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="viewer-header">
|
||||
<div className="image-counter">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<Button
|
||||
type="text"
|
||||
icon={isInfoDrawerOpen ? <InfoCircleOutlined style={{ color: '#1890ff' }} /> : <InfoCircleOutlined />}
|
||||
onClick={() => setIsInfoDrawerOpen(prev => !prev)}
|
||||
className="header-btn"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
className="header-btn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="viewer-footer">
|
||||
<div className="image-name">{currentImage.name}</div>
|
||||
|
||||
<div className="footer-actions">
|
||||
{onFavorite && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={currentImage.isFavorited ?
|
||||
<HeartFilled style={{ color: '#ff4d4f' }} /> :
|
||||
<HeartOutlined style={{ color: '#fff' }} />
|
||||
}
|
||||
onClick={handleFavoriteClick}
|
||||
className="footer-btn"
|
||||
>
|
||||
{showFavoriteCount && typeof currentImage.favoriteCount === 'number' && (
|
||||
<span>{currentImage.favoriteCount}</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Dropdown menu={{ items: albumItems }} disabled={loadingAlbums || albums.length === 0}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FolderAddOutlined style={{ color: '#fff' }} />}
|
||||
className="footer-btn"
|
||||
<div
|
||||
className="image-container"
|
||||
ref={imageContainerRef}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div className="image-transform-wrapper">
|
||||
{currentImage && (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={getImageUrl(currentImage.path)}
|
||||
alt={currentImage.name}
|
||||
style={{
|
||||
transform: `translate(${zoomPanState.positionX}px, ${zoomPanState.positionY}px) rotate(${rotation}deg) scale(${zoomPanState.scale})`,
|
||||
opacity: imageLoaded ? 1 : 0.3,
|
||||
transition: zoomPanState.isDragging ? 'none' :
|
||||
fadeTransition ? 'opacity 0.15s ease, transform 0.1s ease-out' :
|
||||
'transform 0.1s ease-out',
|
||||
cursor: zoomPanState.scale > 1 ? 'grab' : 'auto',
|
||||
transformOrigin: 'center center',
|
||||
willChange: 'opacity, transform'
|
||||
}}
|
||||
className="viewer-img"
|
||||
loading="eager"
|
||||
/>
|
||||
</Dropdown>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DownloadOutlined style={{ color: '#fff' }} />}
|
||||
onClick={() => window.open(currentImage.path, '_blank')}
|
||||
className="footer-btn"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ShareAltOutlined style={{ color: '#fff' }} />}
|
||||
onClick={handleShareClick}
|
||||
className="footer-btn"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(!imageLoaded || currentLoading) && (
|
||||
<div className="image-loading-spinner">
|
||||
<Spin size="large" tip={<span className="loading-text">图片加载中...</span>} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="zoom-controls">
|
||||
<Space>
|
||||
<Button icon={<ExpandOutlined />} onClick={resetTransform} />
|
||||
<Button icon={<ZoomOutOutlined />} onClick={() => zoomOut()} />
|
||||
<Button icon={<ZoomInOutlined />} onClick={() => zoomIn()} />
|
||||
<Button icon={<RotateLeftOutlined />} onClick={() => setRotation(prev => prev - 90)} />
|
||||
<Button icon={<RotateRightOutlined />} onClick={() => setRotation(prev => prev + 90)} />
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentIndex > 0 && (
|
||||
<Button
|
||||
className="nav-button prev-button"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={handlePrevious}
|
||||
shape="circle"
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentIndex < images.length - 1 && (
|
||||
<Button
|
||||
className="nav-button next-button"
|
||||
icon={<RightOutlined />}
|
||||
onClick={handleNext}
|
||||
shape="circle"
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="viewer-header">
|
||||
<div className="image-counter">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<Button
|
||||
type="text"
|
||||
icon={isInfoDrawerOpen ? <InfoCircleOutlined style={{ color: '#1890ff' }} /> : <InfoCircleOutlined />}
|
||||
onClick={() => setIsInfoDrawerOpen(prev => !prev)}
|
||||
className="header-btn"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
className="header-btn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="viewer-footer">
|
||||
<div className="image-name">{currentImage.name}</div>
|
||||
|
||||
<div className="footer-actions">
|
||||
{onFavorite && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={currentImage.isFavorited ?
|
||||
<HeartFilled style={{ color: '#ff4d4f' }} /> :
|
||||
<HeartOutlined style={{ color: '#fff' }} />
|
||||
}
|
||||
onClick={handleFavoriteClick}
|
||||
className="footer-btn"
|
||||
>
|
||||
{showFavoriteCount && typeof currentImage.favoriteCount === 'number' && (
|
||||
<span>{currentImage.favoriteCount}</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Dropdown menu={{ items: albumItems }} disabled={loadingAlbums || albums.length === 0}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FolderAddOutlined style={{ color: '#fff' }} />}
|
||||
className="footer-btn"
|
||||
/>
|
||||
</Dropdown>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DownloadOutlined style={{ color: '#fff' }} />}
|
||||
onClick={() => window.open(currentImage.path, '_blank')}
|
||||
className="footer-btn"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ShareAltOutlined style={{ color: '#fff' }} />}
|
||||
onClick={handleShareClick}
|
||||
className="footer-btn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user