Files
Novault-Frontend-web/src/components/transaction/ImagePreview/ImagePreview.tsx

246 lines
6.2 KiB
TypeScript

/**
* ImagePreview Component
* Full-screen image preview with left/right swipe navigation
*
* Requirements: 4.6, 4.14
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Icon } from '@iconify/react';
import type { TransactionImage } from '../../../types';
import './ImagePreview.css';
export interface ImagePreviewProps {
images: TransactionImage[];
initialIndex: number;
open: boolean;
onClose: () => void;
}
export const ImagePreview: React.FC<ImagePreviewProps> = ({
images,
initialIndex,
open,
onClose,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
const imageContainerRef = useRef<HTMLDivElement>(null);
// Minimum swipe distance (in px) to trigger navigation
const minSwipeDistance = 50;
// Reset index when initialIndex changes
useEffect(() => {
setCurrentIndex(initialIndex);
}, [initialIndex]);
// Prevent body scroll when modal is open
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
// Keyboard navigation
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'ArrowLeft') {
handlePrevious();
} else if (e.key === 'ArrowRight') {
handleNext();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, currentIndex, images.length]);
const handlePrevious = useCallback(() => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
}, [images.length]);
const handleNext = useCallback(() => {
setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
}, [images.length]);
// Touch event handlers for swipe navigation
const onTouchStart = (e: React.TouchEvent) => {
setTouchEnd(null);
setTouchStart(e.targetTouches[0].clientX);
};
const onTouchMove = (e: React.TouchEvent) => {
setTouchEnd(e.targetTouches[0].clientX);
};
const onTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > minSwipeDistance;
const isRightSwipe = distance < -minSwipeDistance;
if (isLeftSwipe) {
handleNext();
} else if (isRightSwipe) {
handlePrevious();
}
setTouchStart(null);
setTouchEnd(null);
};
// Mouse drag handlers for desktop swipe
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<number | null>(null);
const [dragEnd, setDragEnd] = useState<number | null>(null);
const onMouseDown = (e: React.MouseEvent) => {
setIsDragging(true);
setDragEnd(null);
setDragStart(e.clientX);
};
const onMouseMove = (e: React.MouseEvent) => {
if (!isDragging) return;
setDragEnd(e.clientX);
};
const onMouseUp = () => {
if (!isDragging || !dragStart || !dragEnd) {
setIsDragging(false);
return;
}
const distance = dragStart - dragEnd;
const isLeftSwipe = distance > minSwipeDistance;
const isRightSwipe = distance < -minSwipeDistance;
if (isLeftSwipe) {
handleNext();
} else if (isRightSwipe) {
handlePrevious();
}
setIsDragging(false);
setDragStart(null);
setDragEnd(null);
};
const onMouseLeave = () => {
if (isDragging) {
setIsDragging(false);
setDragStart(null);
setDragEnd(null);
}
};
if (!open || images.length === 0) {
return null;
}
const currentImage = images[currentIndex];
const imageUrl = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:2612/api/v1'}/images/${currentImage.id}`;
return (
<div
className="image-preview"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="图片预览"
>
{/* Close button */}
<button
className="image-preview__close"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
type="button"
aria-label="关闭预览"
>
<Icon icon="mdi:close" width="32" />
</button>
{/* Image counter */}
<div className="image-preview__counter">
{currentIndex + 1} / {images.length}
</div>
{/* Previous button */}
{images.length > 1 && (
<button
className="image-preview__nav image-preview__nav--prev"
onClick={(e) => {
e.stopPropagation();
handlePrevious();
}}
type="button"
aria-label="上一张"
>
<Icon icon="mdi:chevron-left" width="48" />
</button>
)}
{/* Image container */}
<div
ref={imageContainerRef}
className="image-preview__container"
onClick={(e) => e.stopPropagation()}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
>
<img
src={imageUrl}
alt={currentImage.fileName}
className="image-preview__image"
draggable={false}
/>
</div>
{/* Next button */}
{images.length > 1 && (
<button
className="image-preview__nav image-preview__nav--next"
onClick={(e) => {
e.stopPropagation();
handleNext();
}}
type="button"
aria-label="下一张"
>
<Icon icon="mdi:chevron-right" width="48" />
</button>
)}
{/* Image info */}
<div className="image-preview__info">
<span className="image-preview__filename">{currentImage.fileName}</span>
<span className="image-preview__filesize">
{(currentImage.fileSize / 1024).toFixed(1)} KB
</span>
</div>
</div>
);
};
export default ImagePreview;