feat: Add zoom and slideshow functionality to PhotoViewer component
This commit enhances the PhotoViewer component by introducing zoom and pan capabilities, allowing users to adjust the view of photos interactively. Additionally, a slideshow feature has been implemented, enabling automatic photo transitions with adjustable intervals. The user interface has been updated to include controls for zooming and starting/stopping the slideshow, improving the overall photo browsing experience. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
72d18ead8c
commit
cd72913cd5
@ -8,11 +8,35 @@ interface PhotoViewerProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ZOOM_MIN = 0.5
|
||||
const ZOOM_MAX = 5
|
||||
const ZOOM_STEP = 0.25
|
||||
const SLIDESHOW_INTERVALS = [
|
||||
{ value: 1, label: '1s' },
|
||||
{ value: 2, label: '2s' },
|
||||
{ value: 3, label: '3s' },
|
||||
{ value: 5, label: '5s' },
|
||||
{ value: 10, label: '10s' },
|
||||
]
|
||||
|
||||
export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoViewerProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
||||
const [imageLoading, setImageLoading] = useState(true)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const preloadedImages = useRef<Set<number>>(new Set())
|
||||
|
||||
// Zoom state
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [panX, setPanX] = useState(0)
|
||||
const [panY, setPanY] = useState(0)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Slideshow state
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [slideshowInterval, setSlideshowInterval] = useState(3) // seconds
|
||||
const slideshowTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const currentPhoto = photos[currentIndex]
|
||||
const canGoPrev = currentIndex > 0
|
||||
@ -49,21 +73,112 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
const goPrev = () => {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1)
|
||||
// Reset zoom when navigating
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
if (currentIndex < photos.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
// Reset zoom when navigating
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Zoom functions
|
||||
const zoomIn = () => {
|
||||
setZoom(prev => Math.min(prev + ZOOM_STEP, ZOOM_MAX))
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
setZoom(prev => Math.max(prev - ZOOM_STEP, ZOOM_MIN))
|
||||
}
|
||||
|
||||
const resetZoom = () => {
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Zoom with Ctrl/Cmd + wheel
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
|
||||
setZoom(prev => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, prev + delta)))
|
||||
}
|
||||
}
|
||||
|
||||
// Pan (drag) functionality
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (zoom > 1) {
|
||||
setIsDragging(true)
|
||||
setDragStart({ x: e.clientX - panX, y: e.clientY - panY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isDragging && zoom > 1) {
|
||||
setPanX(e.clientX - dragStart.x)
|
||||
setPanY(e.clientY - dragStart.y)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
// Slideshow functions
|
||||
const toggleSlideshow = () => {
|
||||
setIsPlaying(prev => !prev)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
slideshowTimerRef.current = setInterval(() => {
|
||||
setCurrentIndex(prev => {
|
||||
if (prev < photos.length - 1) {
|
||||
return prev + 1
|
||||
} else {
|
||||
// Loop back to start or stop
|
||||
setIsPlaying(false)
|
||||
return prev
|
||||
}
|
||||
})
|
||||
// Reset zoom when slideshow advances
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}, slideshowInterval * 1000)
|
||||
} else {
|
||||
if (slideshowTimerRef.current) {
|
||||
clearInterval(slideshowTimerRef.current)
|
||||
slideshowTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (slideshowTimerRef.current) {
|
||||
clearInterval(slideshowTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [isPlaying, slideshowInterval, photos.length])
|
||||
|
||||
// Handle image load
|
||||
useEffect(() => {
|
||||
if (!currentPhoto) return
|
||||
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
// Reset zoom when photo changes
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
|
||||
// Preload adjacent images when current photo changes
|
||||
preloadAdjacent(currentIndex)
|
||||
@ -74,22 +189,42 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
} else if (e.key === 'ArrowLeft' && !isPlaying) {
|
||||
e.preventDefault()
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1)
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
} else if (e.key === 'ArrowRight' && !isPlaying) {
|
||||
e.preventDefault()
|
||||
if (currentIndex < photos.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}
|
||||
} else if (e.key === '+' || e.key === '=') {
|
||||
e.preventDefault()
|
||||
setZoom(prev => Math.min(prev + ZOOM_STEP, ZOOM_MAX))
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
e.preventDefault()
|
||||
setZoom(prev => Math.max(prev - ZOOM_STEP, ZOOM_MIN))
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault()
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
} else if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setIsPlaying(prev => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [currentIndex, photos.length, onClose])
|
||||
}, [currentIndex, photos.length, onClose, isPlaying])
|
||||
|
||||
if (!currentPhoto) {
|
||||
return null
|
||||
@ -99,41 +234,70 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black z-[100] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 bg-black bg-opacity-70 text-white p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Top Left Info Corner */}
|
||||
<div className="absolute top-0 left-0 z-10 bg-black bg-opacity-70 text-white p-2 rounded-br-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-sm"
|
||||
className="px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
✕ Close
|
||||
✕
|
||||
</button>
|
||||
<div className="text-sm">
|
||||
Photo {currentIndex + 1} of {photos.length}
|
||||
<div className="text-xs">
|
||||
{currentIndex + 1} / {photos.length}
|
||||
</div>
|
||||
{currentPhoto.filename && (
|
||||
<div className="text-sm text-gray-300 truncate max-w-md">
|
||||
<div className="text-xs text-gray-300 truncate max-w-xs">
|
||||
{currentPhoto.filename}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentPhoto.date_taken && (
|
||||
<div className="text-sm text-gray-300">
|
||||
{currentPhoto.date_taken}
|
||||
</div>
|
||||
)}
|
||||
{currentPhoto.person_name && (
|
||||
<div className="text-sm text-gray-300">
|
||||
👤 {currentPhoto.person_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Right Play Button */}
|
||||
<div className="absolute top-0 right-0 z-10 bg-black bg-opacity-70 text-white p-2 rounded-bl-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{isPlaying && (
|
||||
<select
|
||||
value={slideshowInterval}
|
||||
onChange={(e) => setSlideshowInterval(Number(e.target.value))}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="px-2 py-1 bg-gray-700 rounded text-xs"
|
||||
title="Slideshow speed"
|
||||
>
|
||||
{SLIDESHOW_INTERVALS.map(interval => (
|
||||
<option key={interval.value} value={interval.value}>
|
||||
{interval.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSlideshow}
|
||||
className={`px-3 py-1 rounded text-xs ${
|
||||
isPlaying
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
}`}
|
||||
title={isPlaying ? 'Pause slideshow (Space)' : 'Start slideshow (Space)'}
|
||||
>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Image Area */}
|
||||
<div className="flex-1 flex items-center justify-center relative overflow-hidden">
|
||||
<div
|
||||
ref={imageContainerRef}
|
||||
className="flex-1 flex items-center justify-center relative overflow-hidden"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
|
||||
>
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black z-20">
|
||||
<div className="text-white text-lg">Loading...</div>
|
||||
@ -145,22 +309,63 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
<div className="text-sm text-gray-400">{currentPhoto.path}</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={currentPhoto.filename || `Photo ${currentIndex + 1}`}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onLoad={() => setImageLoading(false)}
|
||||
onError={() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={currentPhoto.filename || `Photo ${currentIndex + 1}`}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ userSelect: 'none', pointerEvents: 'none' }}
|
||||
onLoad={() => setImageLoading(false)}
|
||||
onError={() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute top-12 right-4 z-30 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= ZOOM_MAX}
|
||||
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Zoom in (Ctrl/Cmd + Wheel)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="px-3 py-1 bg-black bg-opacity-70 text-white rounded text-center text-xs">
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= ZOOM_MIN}
|
||||
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Zoom out (Ctrl/Cmd + Wheel)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
{zoom !== 1 && (
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
className="px-3 py-1 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded text-xs"
|
||||
title="Reset zoom"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<button
|
||||
onClick={goPrev}
|
||||
disabled={!canGoPrev}
|
||||
disabled={!canGoPrev || isPlaying}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 px-4 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed z-30"
|
||||
title="Previous (←)"
|
||||
>
|
||||
@ -168,7 +373,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!canGoNext}
|
||||
disabled={!canGoNext || isPlaying}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 px-4 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed z-30"
|
||||
title="Next (→)"
|
||||
>
|
||||
@ -176,27 +381,6 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 bg-black bg-opacity-70 text-white p-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{currentPhoto.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-400">Tags: </span>
|
||||
{currentPhoto.tags.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{currentPhoto.path && (
|
||||
<div className="text-gray-400 truncate max-w-2xl">
|
||||
{currentPhoto.path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
Use ← → arrow keys to navigate, Esc to close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user