Jake Vanderwerf
2025-11-10 e9967fa22781d922ba4eb8fb44fe72d200ac4b14
assets/js/Gallery.js
@@ -1,272 +1,226 @@
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;