class Gallery { constructor () { this.a11y = window.jvbA11y; this.index = 0; this.images = []; this.zoom = { scale: 1, min: 1, max: 4, threshold: 50, x: 0, y: 0, startX: 0, startY: 0, ease: .2, panning: false }; this.swipe = this.resetSwipe(); this.activePointers = new Map(); this.lastTap = 0; this.initElements(); this.initModal(); this.initListeners(); this.initSubscribers(); } /********************************************************************* ELEMENTS *********************************************************************/ initElements() { this.elements = { imageSelector: 'img[data-gallery]', gallery: { modal: 'dialog.gallery', wrap: '.wrap', nextButton: '.next', prevButton: '.prev', image: '.image', leftImage: '.image-left', rightImage: '.image-right', counter: '.counter' }, } this.ui = window.uiFromSelectors(this.elements); } initModal() { this.modal = new window.jvbModal( this.ui.gallery.modal, { openMessage: 'Opened Gallery', closeMessage: 'Closed Gallery', } ); this.modal.subscribe((event, data) => { if (event === 'modal-close') { this.toggleGallery(false); } }); } buildGalleryItems(filtered = null) { let selector = filtered ? `[data-gallery="${filtered}"]` : this.elements.imageSelector; this.items = Array.from(document.querySelectorAll(selector)) .map((img, index) => { return { id: img.dataset.id||index, srcset: img.srcset || img.src, // Clone the srcset from page sizes: img.sizes || '100vw', src: img.currentSrc || img.src, // Fallback full: img.dataset.full || img.src, alt: img.alt || '', element: img }; }); } /********************************************************************* LISTENERS *********************************************************************/ initListeners() { this.clickHandler = this.handleClick.bind(this); this.pointerDownHandler = this.onPointerDown.bind(this); this.pointerMoveHandler = this.onPointerMove.bind(this); this.pointerUpHandler = this.onPointerUp.bind(this); this.wheelHandler = this.onWheel.bind(this); this.keyHandler = this.handleKeys.bind(this); document.addEventListener('click', this.clickHandler); } handleClick(e) { let target = window.targetCheck(e, this.elements.imageSelector); if (target && !this.modal.isOpen) { e.preventDefault(); this.buildGalleryItems(target.dataset.gallery || null); // Target is now the img element itself this.index = this.items.findIndex(item => item.element === target); this.toggleGallery(true); } else if (this.modal.isOpen) { if (window.targetCheck(e, this.elements.gallery.nextButton)) { console.log('Next'); this.nextElement(); } else if (window.targetCheck(e, this.elements.gallery.prevButton)) { console.log('Previous'); this.prevElement(); } } } handleKeys(e) { if (!this.modal.isOpen) return; switch (e.key) { case 'ArrowLeft': e.preventDefault(); this.prevElement(); break; case 'ArrowRight': e.preventDefault(); this.nextElement(); break; } if (!e.ctrlKey) return; if (e.key === '+' || e.key === '=') { e.preventDefault(); this.handleZoom(.2); // centered } if (e.key === '-') { e.preventDefault(); this.handleZoom(-.2); // centered } if (e.key === '0') { e.preventDefault(); this.resetZoom(); } } onPointerDown(e) { // Always prevent default to stop browser's native image drag e.preventDefault(); this.swipe.startX = e.clientX; this.swipe.startY = e.clientY; this.ui.gallery.image.setPointerCapture(e.pointerId); this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); const now = performance.now(); // DOUBLE-TAP / DOUBLE-CLICK if (now - this.lastTap < 300 && this.activePointers.size === 1) { if (this.zoom.scale > 1) this.resetZoom(); else this.handleZoom(+1, e.clientX, e.clientY); this.lastTap = 0; return; } this.lastTap = now; // PINCH START if (this.activePointers.size === 2) { const pts = [...this.activePointers.values()]; this.pinchStartDist = Math.hypot( pts[0].x - pts[1].x, pts[0].y - pts[1].y ); this.pinchStartScale = this.zoom.scale; return; } // PAN START if (this.zoom.scale > 1) { this.zoom.panning = true; this.zoom.startX = e.clientX - this.zoom.x; this.zoom.startY = e.clientY - this.zoom.y; // Change cursor to grabbing this.ui.gallery.image.style.cursor = 'grabbing'; } } onPointerMove(e) { if (!this.activePointers.has(e.pointerId)) return; this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); // PINCH (two pointers) if (this.activePointers.size === 2) { const pts = [...this.activePointers.values()]; const dist = Math.hypot( pts[0].x - pts[1].x, pts[0].y - pts[1].y ); const newScale = this.pinchStartScale * (dist / this.pinchStartDist); const increment = newScale - this.zoom.scale; // Zoom centered (NO midpoint) this.handleZoom(increment); return; } // PAN (one pointer) if (this.zoom.panning) { this.zoom.x = e.clientX - this.zoom.startX; this.zoom.y = e.clientY - this.zoom.startY; this.applyTransform(); } } onPointerUp(e) { this.activePointers.delete(e.pointerId); if (this.activePointers.size < 2) { this.pinchStartDist = 0; } // Only check for swipe if we weren't panning and no more active pointers if (!this.zoom.panning && this.activePointers.size === 0) { // End of tap or swipe - detect swipe this.swipe.endX = e.clientX; this.swipe.endY = e.clientY; const dx = this.swipe.endX - this.swipe.startX; const dy = this.swipe.endY - this.swipe.startY; if (Math.abs(dx) > this.zoom.threshold) { if (dx > 0) { console.log('Swipe right'); this.prevElement(); } else { console.log('Swipe left'); this.nextElement(); } } } // Reset panning state when all pointers are released if (this.activePointers.size === 0) { this.zoom.panning = false; // Reset cursor based on zoom state this.ui.gallery.image.style.cursor = this.zoom.scale > 1 ? 'grab' : 'default'; } } /* -------------------------------------------------- WHEEL ZOOM (pointer-centered) -------------------------------------------------- */ onWheel(e) { if (!e.ctrlKey) return; e.preventDefault(); const increment = (e.deltaY < 0) ? +0.2 : -0.2; this.handleZoom(increment, e.clientX, e.clientY); } clampPan() { const BORDER = 32; // 2rem const wrap = this.ui.gallery.wrap; if (!wrap) return; const wrapRect = wrap.getBoundingClientRect(); // MUST use natural dimensions const naturalW = 1920; const naturalH = 1920; // But they must be constrained by how the CSS fits them (max-width: 90vw, max-height: 85vh) // So compute the base display size: const displayRatio = Math.min( wrapRect.width / naturalW, wrapRect.height / naturalH ); const baseWidth = naturalW * displayRatio; const baseHeight = naturalH * displayRatio; const scaledWidth = baseWidth * this.zoom.scale; const scaledHeight = baseHeight * this.zoom.scale; // Allowed pan range const minX = wrapRect.width - scaledWidth - BORDER; const maxX = BORDER; const minY = wrapRect.height - scaledHeight - BORDER; const maxY = BORDER; // clamp this.zoom.x = Math.min(maxX, Math.max(minX, this.zoom.x)); this.zoom.y = Math.min(maxY, Math.max(minY, this.zoom.y)); } handleZoom(increment, clientX=null, clientY=null) { const oldScale = this.zoom.scale; let newScale = oldScale + increment; newScale = Math.min(this.zoom.max, Math.max(this.zoom.min, newScale)); if (newScale === oldScale) return; const ratio = newScale / oldScale; // default: center of image let rect = this.ui.gallery.image.getBoundingClientRect(); if (clientX === null || clientY === null) { clientX = rect.left + rect.width / 2; clientY = rect.top + rect.height / 2; } const localX = clientX - rect.left; const localY = clientY - rect.top; this.zoom.x = (this.zoom.x - localX) * ratio + localX; this.zoom.y = (this.zoom.y - localY) * ratio + localY; this.zoom.scale = newScale; this.applyTransform(); this.notify("zoom", { scale: this.zoom.scale }); } applyTransform() { // this.clampPan(); const img = this.ui.gallery.image; img.style.transform = `translate(${this.zoom.x}px, ${this.zoom.y}px) scale(${this.zoom.scale})`; // Update cursor based on zoom level img.style.cursor = this.zoom.scale > 1 ? 'grab' : 'default'; } resetZoom() { this.zoom.scale = 1; this.zoom.x = 0; this.zoom.y = 0; this.zoom.startX = 0; this.zoom.startY = 0; this.zoom.panning = false; this.applyTransform(); } resetSwipe() { return { startX: null, startY: null, endX: null, endY: null }; } /** * * @param {boolean} open * @param {null|number} index */ toggleGallery(open, index= null) { if (open) { // Disable native image dragging this.ui.gallery.image.draggable = false; this.ui.gallery.image.style.userSelect = 'none'; this.ui.gallery.image.addEventListener("pointerdown", this.pointerDownHandler); this.ui.gallery.image.addEventListener("pointermove", this.pointerMoveHandler); this.ui.gallery.image.addEventListener("pointerup", this.pointerUpHandler); this.ui.gallery.image.addEventListener("pointercancel", this.pointerUpHandler); window.addEventListener("wheel", this.wheelHandler, {passive: false}); window.addEventListener("keydown", this.keyHandler); this.moveIntoView(); } else { this.ui.gallery.image.removeEventListener("pointerdown", this.pointerDownHandler); this.ui.gallery.image.removeEventListener("pointermove", this.pointerMoveHandler); this.ui.gallery.image.removeEventListener("pointerup", this.pointerUpHandler); this.ui.gallery.image.removeEventListener("pointercancel", this.pointerUpHandler); window.removeEventListener("wheel", this.wheelHandler); window.removeEventListener("keydown", this.keyHandler); this.resetZoom(); this.resetSwipe(); this.activePointers.clear(); this.lastTap = 0; } if (open && !this.modal.isOpen) { this.modal.handleOpen(); } } /********************************************************************* GALLERY FUNCTIONALITY *********************************************************************/ moveIntoView(index = 0) { let newIndex = this.index + index; //Wrap around if (newIndex < 0) { newIndex = this.items.length - 1; } else if (newIndex >= this.items.length) { newIndex = 0; } else if (newIndex === this.items.length - 3) { this.notify('load-more'); } this.index = newIndex; this.updateDisplay(); this.preloadAdjacent(); this.a11y.announce(`Image ${this.index + 1} of ${this.items.length}`); } nextElement() { this.resetZoom(); this.moveIntoView(1); } prevElement() { this.resetZoom(); this.moveIntoView(-1); } updateDisplay() { const item = this.items[this.index]; if (!item) return; const galleryImg = this.ui.gallery.image; // Set srcset first - browser uses cached version instantly (no wait) if (item.srcset) { galleryImg.srcset = item.srcset; galleryImg.sizes = item.sizes; } galleryImg.src = item.src; // Fallback galleryImg.alt = item.alt; // ALWAYS load full resolution for zoom quality if (item.full && item.full !== item.src) { const fullImg = new Image(); fullImg.onload = () => { if (this.items[this.index] === item) { galleryImg.src = item.full; galleryImg.removeAttribute('srcset'); // Switch to full res directly galleryImg.removeAttribute('sizes'); } }; fullImg.src = item.full; } this.ui.gallery.counter.textContent = `${this.index + 1} / ${this.items.length}`; this.ui.gallery.prevButton.disabled = this.items.length <= 1; this.ui.gallery.nextButton.disabled = this.items.length <= 1; } preloadAdjacent() { [-1, 1].forEach(offset => { const index = this.index + offset; if (index >0 && index < this.items.length) { const item = this.items[index]; const img = offset < 0 ? this.ui.gallery.leftImage : this.ui.gallery.rightImage; img.src = item.full; } }); } /********************************************************************* SUBSCRIBERS *********************************************************************/ initSubscribers() { this.subscribers = new Set(); } subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data = {}) { this.subscribers.forEach( callback => { try { callback(event, data); } catch (error) { console.error('Subscriber error:', error); } }); } /****************************************************************** CLEANUP ******************************************************************/ destroy() { this.subscribers.clear(); this.toggleGallery(false); document.removeEventListener('click', this.clickHandler); } } document.addEventListener('DOMContentLoaded', function() { if (document.querySelector('dialog.gallery')) { window.jvbGallery = new Gallery(); } });