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();
|
console.log('Gallery loaded...');
|
}
|
/*********************************************************************
|
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) => {
|
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 increment = this.pinchStartScale * (dist / this.pinchStartDist) - this.zoom.scale;
|
|
const midX = (pts[0].x + pts[1].x) / 2; // ← anchor to finger midpoint
|
const midY = (pts[0].y + pts[1].y) / 2;
|
this.handleZoom(increment, midX, midY);
|
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) {
|
const last = this.activePointers.get(e.pointerId); // grab before delete
|
this.activePointers.delete(e.pointerId);
|
|
if (this.activePointers.size < 2) {
|
this.pinchStartDist = 0;
|
}
|
|
if (this.zoom.scale <= 1 && !this.zoom.panning && this.activePointers.size === 0) {
|
const endX = last?.x ?? e.clientX; // use tracked position, fall back to event
|
const endY = last?.y ?? e.clientY;
|
const dx = endX - this.swipe.startX;
|
const dy = endY - this.swipe.startY;
|
|
if (Math.abs(dx) > this.zoom.threshold && Math.abs(dx) > Math.abs(dy)) {
|
dx > 0 ? this.prevElement() : this.nextElement();
|
}
|
}
|
|
if (this.activePointers.size === 0) {
|
this.zoom.panning = false;
|
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);
|
}
|
|
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';
|
}
|
clampPan() {
|
const img = this.ui.gallery.image;
|
const excessX = Math.max(0, (img.offsetWidth * this.zoom.scale - window.innerWidth) / 2);
|
const excessY = Math.max(0, (img.offsetHeight * this.zoom.scale - window.innerHeight) / 2);
|
this.zoom.x = Math.max(-excessX, Math.min(excessX, this.zoom.x));
|
this.zoom.y = Math.max(-excessY, Math.min(excessY, this.zoom.y));
|
}
|
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
|
*/
|
toggleGallery(open) {
|
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();
|
}
|
});
|