init
This commit is contained in:
245
src/components/transaction/ImagePreview/ImagePreview.tsx
Normal file
245
src/components/transaction/ImagePreview/ImagePreview.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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:8080/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;
|
||||
Reference in New Issue
Block a user