feat: add ProductDetailPage introduce ImageViewer and ImageLightbox components for enhanced image display
- ProductDetailPage is being used in lieu of a modal becaues Lightbox image gallery (modal) being embedded in another modal was causing too much buggy behavior - Added ImageViewer component to manage and display product images with features like lightbox, thumbnails, and image cycling controls. - Replaced ProgressiveImageGallery with ImageViewer in ProductDetailDialog and ProductDetailPage for improved user experience and maintainability. - Implemented useImageLightbox composable to handle lightbox functionality, including keyboard navigation and swipe gestures. - Updated routing to include a dedicated product detail page for better navigation and user flow. These changes significantly enhance the image viewing experience in the product detail context, providing a more dynamic and user-friendly interface.
This commit is contained in:
parent
bff158cb74
commit
3aec5bbdb3
8 changed files with 1100 additions and 384 deletions
188
src/composables/useImageLightbox.ts
Normal file
188
src/composables/useImageLightbox.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
|
||||
export interface LightboxImage {
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export interface UseImageLightboxOptions {
|
||||
/**
|
||||
* Whether to enable keyboard navigation (arrow keys, escape)
|
||||
*/
|
||||
enableKeyboardNavigation?: boolean
|
||||
|
||||
/**
|
||||
* Whether to close lightbox when clicking backdrop
|
||||
*/
|
||||
closeOnBackdropClick?: boolean
|
||||
|
||||
/**
|
||||
* Whether to enable swipe gestures on touch devices
|
||||
*/
|
||||
enableSwipeGestures?: boolean
|
||||
}
|
||||
|
||||
export function useImageLightbox(
|
||||
images: LightboxImage[],
|
||||
options: UseImageLightboxOptions = {}
|
||||
) {
|
||||
const {
|
||||
enableKeyboardNavigation = true,
|
||||
closeOnBackdropClick = true,
|
||||
enableSwipeGestures = true
|
||||
} = options
|
||||
|
||||
// Core reactive state
|
||||
const isOpen = ref(false)
|
||||
const currentIndex = ref(0)
|
||||
|
||||
// Computed properties
|
||||
const currentImage = computed(() => {
|
||||
if (!images.length || currentIndex.value < 0) return null
|
||||
return images[Math.min(currentIndex.value, images.length - 1)]
|
||||
})
|
||||
|
||||
const hasPrevious = computed(() => images.length > 1)
|
||||
const hasNext = computed(() => images.length > 1)
|
||||
const totalImages = computed(() => images.length)
|
||||
|
||||
// Navigation methods
|
||||
const open = (index: number = 0) => {
|
||||
if (images.length === 0) return
|
||||
|
||||
currentIndex.value = Math.max(0, Math.min(index, images.length - 1))
|
||||
isOpen.value = true
|
||||
|
||||
// Prevent body scroll when lightbox is open
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
|
||||
// Restore body scroll
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const goToPrevious = () => {
|
||||
if (!hasPrevious.value) return
|
||||
|
||||
currentIndex.value = currentIndex.value > 0
|
||||
? currentIndex.value - 1
|
||||
: images.length - 1
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (!hasNext.value) return
|
||||
|
||||
currentIndex.value = currentIndex.value < images.length - 1
|
||||
? currentIndex.value + 1
|
||||
: 0
|
||||
}
|
||||
|
||||
const goToIndex = (index: number) => {
|
||||
if (index < 0 || index >= images.length) return
|
||||
currentIndex.value = index
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!isOpen.value || !enableKeyboardNavigation) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
close()
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault()
|
||||
goToPrevious()
|
||||
break
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
goToNext()
|
||||
break
|
||||
case ' ': // Spacebar
|
||||
event.preventDefault()
|
||||
goToNext()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Touch/swipe gesture handling
|
||||
let touchStartX = 0
|
||||
let touchStartY = 0
|
||||
const swipeThreshold = 50
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
if (!enableSwipeGestures || !isOpen.value) return
|
||||
|
||||
touchStartX = event.touches[0].clientX
|
||||
touchStartY = event.touches[0].clientY
|
||||
}
|
||||
|
||||
const handleTouchEnd = (event: TouchEvent) => {
|
||||
if (!enableSwipeGestures || !isOpen.value) return
|
||||
|
||||
const touchEndX = event.changedTouches[0].clientX
|
||||
const touchEndY = event.changedTouches[0].clientY
|
||||
|
||||
const deltaX = touchEndX - touchStartX
|
||||
const deltaY = touchEndY - touchStartY
|
||||
|
||||
// Only process horizontal swipes (ignore mostly vertical swipes)
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > swipeThreshold) {
|
||||
if (deltaX > 0) {
|
||||
goToPrevious()
|
||||
} else {
|
||||
goToNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Setup event listeners
|
||||
watch(isOpen, (newIsOpen) => {
|
||||
if (newIsOpen) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('touchstart', handleTouchStart)
|
||||
document.removeEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onBeforeUnmount(() => {
|
||||
close()
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('touchstart', handleTouchStart)
|
||||
document.removeEventListener('touchend', handleTouchEnd)
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isOpen: readonly(isOpen),
|
||||
currentIndex: readonly(currentIndex),
|
||||
currentImage,
|
||||
|
||||
// Computed
|
||||
hasPrevious,
|
||||
hasNext,
|
||||
totalImages,
|
||||
|
||||
// Methods
|
||||
open,
|
||||
close,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
goToIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create readonly refs
|
||||
function readonly<T>(ref: import('vue').Ref<T>) {
|
||||
return computed(() => ref.value)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue