| | |
| | | class Gallery { |
| | | constructor(modal, config) { |
| | | this.imageWrapper = config.imageWrapper??null; |
| | | this.container = config.container ? document.querySelector(config.container) : document.querySelector('main'); |
| | | this.modal = new window.jvbModal( |
| | | modal, |
| | | { |
| | | constructor(modal, config = {}) { |
| | | this.container = config.container |
| | | ? document.querySelector(config.container) |
| | | : document.querySelector('main'); |
| | | |
| | | this.gallerySelector = config.gallerySelector || 'img[data-small]'; |
| | | |
| | | this.modal = new window.jvbModal(modal, { |
| | | onOpen: false, |
| | | onSave: false, |
| | | onClose: () => this.closeGallery(false) |
| | | } |
| | | ); |
| | | this.items = this.getGalleryItems() || []; |
| | | this.loadMore = config.loadMore??false; |
| | | }); |
| | | |
| | | this.modalElement = (typeof modal === 'string') ? document.querySelector(modal)??false : modal; |
| | | if (!this.modal) { |
| | | return; |
| | | } |
| | | this.modalElement = (typeof modal === 'string') |
| | | ? document.querySelector(modal) |
| | | : modal; |
| | | |
| | | if (!this.modalElement) return; |
| | | |
| | | this.a11y = window.jvbA11y; |
| | | this.openWhenReady = false; |
| | | |
| | | |
| | | this.initElements(); |
| | | this.imageWrapper = (typeof this.imageWrapper === 'string') ? this.imageWrapper : '.'+this.imageWrapper.classList.join('.'); |
| | | |
| | | this.index = 0; |
| | | this.items = []; |
| | | |
| | | this.swipe = { |
| | | touchStart: null, |
| | | touchEnd: null, |
| | | minSwipe: 50, |
| | | }; |
| | | this.isLoading = false; |
| | | |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | } |
| | | |
| | | initElements() { |
| | | this.prevBtn = this.modalElement.querySelector('.prev'); |
| | | this.nextBtn = this.modalElement.querySelector('.next'); |
| | | this.favourite = this.modalElement.querySelector('.favourite'); |
| | | this.image = this.modalElement.querySelector('.image'); |
| | | this.counter = this.modalElement.querySelector('.counter'); |
| | | this.extra = this.modalElement.querySelector('.item-info'); |
| | | |
| | | //If we don't have the wrapper set up, we can remove these elements |
| | | if (!this.imageWrapper) { |
| | | this.favourite.remove(); |
| | | this.extra.remove(); |
| | | } |
| | | } |
| | | |
| | | getGalleryItems() { |
| | | let search = (this.imageWrapper) ? this.imageWrapper : 'img'; |
| | | |
| | | return Array.from(this.container.querySelectorAll(search)) |
| | | .map(item => { |
| | | const img = (this.imageWrapper) ? item.querySelector('img') : item; |
| | | if (!img) return null; |
| | | |
| | | return { |
| | | id: (this.imageWrapper) ? item.querySelector('button.favourite').dataset.id : '', |
| | | small: img.dataset.small || img.src, |
| | | large: img.dataset.large || img.src, |
| | | full: img.dataset.full || img.src, |
| | | alt: img.alt || '', |
| | | fav: (this.imageWrapper) ? item.querySelector('button.favourite')?.cloneNode(true) : '', |
| | | info: (this.imageWrapper) ? item.querySelector('.item-info')?.cloneNode(true) : '' |
| | | }; |
| | | }).filter(Boolean); |
| | | } |
| | | |
| | | initListeners() { |
| | | // Delegate click handling to container |
| | | this.container.addEventListener('click', this.handleClick.bind(this)); |
| | | document.addEventListener('keydown', this.handleKeys.bind(this)); |
| | | document.addEventListener('touchstart', this.handleTouchStart.bind(this)); |
| | | document.addEventListener('touchend', this.handleTouchEnd.bind(this)); |
| | | document.addEventListener('touchmove', this.handleTouchMove.bind(this)); |
| | | |
| | | // Modal-specific listeners added only when open |
| | | this.boundKeyHandler = this.handleKeys.bind(this); |
| | | this.boundTouchStart = this.handleTouchStart.bind(this); |
| | | this.boundTouchMove = this.handleTouchMove.bind(this); |
| | | this.boundTouchEnd = this.handleTouchEnd.bind(this); |
| | | } |
| | | destroyListeners() { |
| | | this.container.removeEventListener('click', this.handleClick.bind(this)); |
| | | document.removeEventListener('keydown', this.handleKeys.bind(this)); |
| | | document.removeEventListener('touchstart', this.handleTouchStart.bind(this)); |
| | | document.removeEventListener('touchend', this.handleTouchEnd.bind(this)); |
| | | document.removeEventListener('touchmove', this.handleTouchMove.bind(this)); |
| | | |
| | | handleClick(e) { |
| | | // Open gallery when clicking images |
| | | const img = e.target.closest(this.gallerySelector); |
| | | if (img && !this.modal.isOpen) { |
| | | e.preventDefault(); |
| | | this.openGallery(img); |
| | | return; |
| | | } |
| | | |
| | | // Navigation within gallery |
| | | if (this.modal.isOpen) { |
| | | if (e.target.closest('.next')) { |
| | | this.navigate(1); |
| | | } else if (e.target.closest('.prev')) { |
| | | this.navigate(-1); |
| | | } |
| | | } |
| | | } |
| | | |
| | | handleKeys(e) { |
| | | if (!this.modal.isOpen) return; |
| | | |
| | | switch (e.key) { |
| | | case 'ArrowLeft': |
| | | e.preventDefault(); |
| | | this.navigate(-1); |
| | | break; |
| | | case 'ArrowRight': |
| | | e.preventDefault(); |
| | | this.navigate(1); |
| | | break; |
| | | } |
| | | } |
| | | |
| | | handleTouchStart(e) { |
| | | if (!this.modal.isOpen) return; |
| | | this.swipe.touchStart = e.touches[0].clientX; |
| | | } |
| | | |
| | | handleTouchMove(e) { |
| | | if (!this.modal.isOpen) return; |
| | | this.swipe.touchEnd = e.touches[0].clientX; |
| | | } |
| | | |
| | | handleTouchEnd(e) { |
| | | if (!this.swipe.touchStart || !this.swipe.touchEnd) return; |
| | | const distance = this.swipe.touchStart - this.swipe.touchEnd; |
| | | const isLeftSwipe = distance > this.swipe.minSwipe; |
| | | const isRightSwipe = distance < -this.swipe.minSwipe; |
| | | if (!this.modal.isOpen || !this.swipe.touchStart || !this.swipe.touchEnd) return; |
| | | |
| | | if (isLeftSwipe) { |
| | | this.navigate(1); |
| | | } else if (isRightSwipe) { |
| | | this.navigate(-1); |
| | | const distance = this.swipe.touchStart - this.swipe.touchEnd; |
| | | |
| | | if (Math.abs(distance) > this.swipe.minSwipe) { |
| | | this.navigate(distance > 0 ? 1 : -1); |
| | | } |
| | | |
| | | this.swipe.touchStart = null; |
| | | this.swipe.touchEnd = null; |
| | | } |
| | | |
| | | handleClick(e) { |
| | | //test if it's a link click, and the click has an image |
| | | if(window.targetCheck(e, '.feed-images')){ |
| | | this.handleGalleryOpen(e); |
| | | buildGalleryItems() { |
| | | return Array.from(this.container.querySelectorAll(this.gallerySelector)) |
| | | .map((img, index) => ({ |
| | | id: img.dataset.id || index, |
| | | small: img.dataset.small || img.src, |
| | | medium: img.dataset.medium || img.src, |
| | | full: img.dataset.full || img.src, |
| | | alt: img.alt || '', |
| | | element: img |
| | | })); |
| | | } |
| | | |
| | | if(window.targetCheck(e, '.nav')) { |
| | | if (window.targetCheck(e, '.next')) { |
| | | this.navigate(1); |
| | | } else { |
| | | this.navigate(-1); |
| | | } |
| | | } |
| | | if (window.targetCheck(e, 'button.cancel')) { |
| | | this.closeGallery(); |
| | | } |
| | | } |
| | | openGallery(clickedImg) { |
| | | // Build fresh gallery items |
| | | this.items = this.buildGalleryItems(); |
| | | |
| | | async navigate(direction) { |
| | | let newIndex = this.index + direction; |
| | | |
| | | //Check if out of bounds |
| | | if (newIndex <0 || newIndex >= this.items.length) { |
| | | this.a11y.announceNavigation(newIndex, this.items.length, direction < 0, direction > 0); |
| | | if (newIndex <0) { |
| | | newIndex = this.items.length - 1; |
| | | } else { |
| | | newIndex = 0; |
| | | } |
| | | } |
| | | |
| | | //update index |
| | | this.index = newIndex; |
| | | |
| | | this.updateDisplay(); |
| | | this.preloadImages(); |
| | | |
| | | this.a11y.announceNavigation(this.index, this.items.length); |
| | | |
| | | if (typeof this.loadMore === 'function' && |
| | | direction > 0 && |
| | | newIndex >= (this.items.length - 3)) { |
| | | if (window.feedBlock.hasMore) { |
| | | await this.loadMore(); |
| | | this.updateGalleryItems(this.getGalleryItems()); |
| | | } |
| | | } |
| | | } |
| | | |
| | | updateGalleryItems(newItems) { |
| | | const currentItem = this.items[this.index]; |
| | | |
| | | this.items = newItems; |
| | | if (currentItem) { |
| | | const newIndex = this.items.findIndex(item => |
| | | item.full === currentItem.full || |
| | | item.large === currentItem.large |
| | | // Find clicked image index |
| | | this.index = this.items.findIndex(item => |
| | | item.element === clickedImg |
| | | ); |
| | | |
| | | if (newIndex !== -1) { |
| | | this.index = newIndex; |
| | | if (this.index === -1) this.index = 0; |
| | | |
| | | // Attach modal-specific listeners |
| | | document.addEventListener('keydown', this.boundKeyHandler); |
| | | document.addEventListener('touchstart', this.boundTouchStart, { passive: true }); |
| | | document.addEventListener('touchmove', this.boundTouchMove, { passive: true }); |
| | | document.addEventListener('touchend', this.boundTouchEnd, { passive: true }); |
| | | |
| | | this.modal.handleOpen(); |
| | | this.updateDisplay(); |
| | | this.preloadAdjacent(); |
| | | |
| | | this.a11y.announce( |
| | | `Gallery opened. Image ${this.index + 1} of ${this.items.length}. Use arrow keys to navigate.` |
| | | ); |
| | | } |
| | | |
| | | closeGallery(useModal = true) { |
| | | // Remove modal-specific listeners |
| | | document.removeEventListener('keydown', this.boundKeyHandler); |
| | | document.removeEventListener('touchstart', this.boundTouchStart); |
| | | document.removeEventListener('touchmove', this.boundTouchMove); |
| | | document.removeEventListener('touchend', this.boundTouchEnd); |
| | | |
| | | if (useModal) { |
| | | this.modal.handleClose(); |
| | | } |
| | | } |
| | | |
| | | this.updateNavigationButtons(); |
| | | if(this.items.length > 0 && this.openWhenReady) { |
| | | let index = this.findIndex('id', this.openWhenReady); |
| | | this.openGallery(index); |
| | | navigate(direction) { |
| | | let newIndex = this.index + direction; |
| | | |
| | | // Wrap around |
| | | if (newIndex < 0) { |
| | | newIndex = this.items.length - 1; |
| | | } else if (newIndex >= this.items.length) { |
| | | newIndex = 0; |
| | | } |
| | | } |
| | | preloadImages() { |
| | | [-1,0,1].forEach(offset => { |
| | | const index = this.index + offset; |
| | | if (index >=0 && index < this.items.length) { |
| | | const img = new Image(); |
| | | const item = this.items[index]; |
| | | if (window.innerWidth < 1000) { |
| | | img.src = item.large || item.src; |
| | | } else { |
| | | img.src = item.full || item.src; |
| | | } |
| | | } |
| | | }); |
| | | |
| | | this.index = newIndex; |
| | | this.updateDisplay(); |
| | | this.preloadAdjacent(); |
| | | |
| | | this.a11y.announce(`Image ${this.index + 1} of ${this.items.length}`); |
| | | } |
| | | |
| | | updateDisplay() { |
| | | const item = this.items[this.index]; |
| | | if (!item) return; |
| | | |
| | | [ |
| | | this.image.src, |
| | | this.image.alt, |
| | | this.counter.textContent |
| | | ] = [ |
| | | (window.innerWidth < 1000) ? |
| | | (item.large || item.src) : |
| | | (item.full || item.src), |
| | | item.alt || '', |
| | | `${this.index + 1} / ${this.items.length}` |
| | | ]; |
| | | if (this.imageWrapper) { |
| | | if (item.fav) { |
| | | window.removeChildren(this.favourite); |
| | | this.favourite.appendChild(item.fav.cloneNode(true)); |
| | | // Use medium/full based on viewport |
| | | const src = window.innerWidth < 1000 |
| | | ? (item.medium || item.src) |
| | | : (item.full || item.src); |
| | | |
| | | this.image.src = src; |
| | | this.image.alt = item.alt; |
| | | this.counter.textContent = `${this.index + 1} / ${this.items.length}`; |
| | | |
| | | // Update button states |
| | | this.prevBtn.classList.toggle('disabled', this.items.length <= 1); |
| | | this.nextBtn.classList.toggle('disabled', this.items.length <= 1); |
| | | } |
| | | if (item.info) { |
| | | window.removeChildren(this.extra); |
| | | this.extra.appendChild(item.info.cloneNode(true)); |
| | | |
| | | preloadAdjacent() { |
| | | [-1, 1].forEach(offset => { |
| | | const index = this.index + offset; |
| | | if (index >= 0 && index < this.items.length) { |
| | | const item = this.items[index]; |
| | | const img = new Image(); |
| | | img.src = window.innerWidth < 1000 |
| | | ? (item.medium || item.src) |
| | | : (item.full || item.src); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | cleanup() { |
| | | this.container.removeEventListener('click', this.handleClick); |
| | | this.closeGallery(false); |
| | | } |
| | | } |
| | | |
| | | this.updateNavigationButtons(); |
| | | } |
| | | |
| | | updateNavigationButtons() { |
| | | this.prevBtn.classList.toggle('end', this.index > 0 ? '' : 'none'); |
| | | this.nextBtn.classList.toggle('end', this.index < this.items.length -1 ? '' : 'none'); |
| | | } |
| | | document.addEventListener('DOMContentLoaded', function() { |
| | | let galleries = document.querySelectorAll('dialog.gallery'); |
| | | if (galleries.length > 0) { |
| | | window.galleries = new Map(); |
| | | |
| | | handleGalleryOpen(e) { |
| | | let item = (this.imageWrapper) ? e.target.closest(this.imageWrapper) : e.target.closest('img'); |
| | | let key = (this.imageWrapper) ? 'id' : 'small'; |
| | | let value = item.dataset[key]; |
| | | |
| | | let index = this.findIndex(key, value); |
| | | this.openGallery(index); |
| | | galleries.forEach(gallery => { |
| | | let id = gallery.id; |
| | | window.galleries.set(id, new Gallery(gallery)); |
| | | }); |
| | | } |
| | | }); |
| | | |
| | | findIndex(property, value) { |
| | | return this.items.findIndex(obj => obj[property] === value); |
| | | } |
| | | |
| | | openGallery(index) { |
| | | this.initListeners(); |
| | | this.index = index; |
| | | this.modal.handleOpen(); |
| | | this.updateDisplay(); |
| | | this.preloadImages(); |
| | | |
| | | this.a11y.announce(`Image ${this.index + 1} of ${this.items.length}. Use arrow keys to navigate.`); |
| | | } |
| | | |
| | | closeGallery(useModal = true) { |
| | | this.destroyListeners(); |
| | | if (useModal) { |
| | | this.modal.handleClose(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | window.jvbGallery = Gallery; |