From 17f900aac29d08d838f701ae3595e082e37f3301 Mon Sep 17 00:00:00 2001 From: shiyu Date: Wed, 21 May 2025 19:47:40 +0800 Subject: [PATCH] refactor(ImageViewer): optimize image loading and zoom functionality --- View/src/components/image/ImageViewer.css | 39 +- View/src/components/image/ImageViewer.tsx | 527 +++++++++++++++------- 2 files changed, 388 insertions(+), 178 deletions(-) diff --git a/View/src/components/image/ImageViewer.css b/View/src/components/image/ImageViewer.css index 58abf0c..833fabc 100644 --- a/View/src/components/image/ImageViewer.css +++ b/View/src/components/image/ImageViewer.css @@ -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; } diff --git a/View/src/components/image/ImageViewer.tsx b/View/src/components/image/ImageViewer.tsx index e92d4d7..94b5723 100644 --- a/View/src/components/image/ImageViewer.tsx +++ b/View/src/components/image/ImageViewer.tsx @@ -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 = ({ visible, onClose, @@ -53,62 +62,125 @@ const ImageViewer: React.FC = ({ const [localImages, setLocalImages] = useState(images); const [shareDialogVisible, setShareDialogVisible] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); + const [currentLoading, setCurrentLoading] = useState(false); - // 修改:使用 useRef 存储图片缓存,避免重复加载 + const [zoomPanState, setZoomPanState] = useState({ + scale: 1, + positionX: 0, + positionY: 0, + isDragging: false, + dragStartX: 0, + dragStartY: 0, + lastPositionX: 0, + lastPositionY: 0, + }); + + const imageContainerRef = useRef(null); + const imageRef = useRef(null); const imageCache = useRef({}); - - // 替换原来的 cacheKey const sessionKey = useRef(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(null); + + // 预渲染图片容器 + const preloadedImagesRef = useRef<{[key: string]: HTMLImageElement}>({}); + + // 图片过渡状态 + const [fadeTransition, setFadeTransition] = useState(false); + const [, setActiveImage] = useState(null); + const loadImage = useCallback((imageUrl: string): Promise => { 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 = ({ 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 = ({ 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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 = ({
-
- - {({ zoomIn, zoomOut, resetTransform }) => ( - <> - - {currentImage && ( - {currentImage.name} - )} - - - {!imageLoaded && ( -
- 图片加载中...} /> -
- )} - -
- -
- - )} -
- - {currentIndex > 0 && ( -
- -
-
- {currentIndex + 1} / {images.length} -
-
-
-
- -
-
{currentImage.name}
- -
- {onFavorite && ( - - )} - -
+ + {(!imageLoaded || currentLoading) && ( +
+ 图片加载中...} /> +
+ )} + +
+ +
+
+ + {currentIndex > 0 && ( +
+ +
+
+ {currentIndex + 1} / {images.length} +
+
+
+
+ +
+
{currentImage.name}
+ +
+ {onFavorite && ( + + )} + +