246 lines
6.2 KiB
TypeScript
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;
|