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:
tanyar09 2025-11-13 12:07:30 -05:00
parent 72d18ead8c
commit cd72913cd5

View File

@ -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>
)
}