I am running Dawn 15 with Magic Zoom Plus as product page gallery app.
However, the Magic Zoom Plus app does not work that well with my Dawn theme so I decided to see if I could make my own logic with ChatGPT.
I am NOT a developer at all - just for reference.
Today (live with Magic Zoom) it looks like this:
And here is my custom solution (preview):
I have one issue:
I would like the desktop active image zoom (not modal) on the PLP to have the same zoom function as my Magic Zoom app.
As you can see - right now the zoom pan seems slow - and I cannot move the cursor around and see the entire, zoomed image - only parts of it.
ChatGPT seems stuck and looping over the same faulty solutions, so I hope someone can help me adjust it, so the user can zoom and see the entire image in the ‘inner’ zoom by moving the cursor (like my live theme gallery).
Below is my javascript handling this.
class CustomProductGallery {
constructor(element) {
this.gallery = element;
if (!this.gallery) return;
// Main
this.gallerySlider = this.gallery.querySelector('.gallery-slider');
this.slides = this.gallery.querySelectorAll('.gallery-slide');
this.dots = this.gallery.querySelectorAll('.gallery-dot');
this.thumbnails = this.gallery.querySelectorAll('.gallery-thumbnail');
// Modal
this.modal = document.querySelector('[data-modal]');
if (!this.modal) return;
this.modalSliderViewport = this.modal.querySelector('.modal-slider-viewport');
this.modalSliderTrack = this.modal.querySelector('.modal-slider-track');
this.modalSlides = this.modal.querySelectorAll('.modal-slide');
// Modal thumbs
this.modalThumbsWrapper = this.modal.querySelector('.modal-thumbnails-wrapper');
this.modalThumbsViewport = this.modal.querySelector('.modal-thumbnails-container');
this.modalThumbsTrack = this.modal.querySelector('.modal-thumbnails');
this.modalThumbnails = this.modal.querySelectorAll('.modal-thumbnail');
this.currentIndex = 0;
this.totalSlides = this.slides.length;
this.triggerElement = null;
// Zoom caps
this.maxPlpZoom = 1.2; // desktop PLP hover zoom cap
this.maxModalZoom = 1.8; // modal click zoom cap
this.init();
}
init() {
this.relocateModalArrows();
this.bindAll();
this.setupSwipeGestures();
this.updateThumbLayout();
window.addEventListener('resize', () => this.updateThumbLayout(), { passive: true });
this.setupPlpHoverZoom(); // desktop PLP hover zoom
}
relocateModalArrows() {
if (!this.modalSliderViewport) return;
const prev = this.modal.querySelector('[data-modal-prev]');
const next = this.modal.querySelector('[data-modal-next]');
if (prev && prev.parentElement !== this.modalSliderViewport) this.modalSliderViewport.appendChild(prev);
if (next && next.parentElement !== this.modalSliderViewport) this.modalSliderViewport.appendChild(next);
}
bindAll() {
this.gallery.querySelector('[data-prev]')?.addEventListener('click', () => this.prevSlide());
this.gallery.querySelector('[data-next]')?.addEventListener('click', () => this.nextSlide());
this.dots.forEach((dot, i) => dot.addEventListener('click', () => this.goToSlide(i)));
this.thumbnails.forEach((t, i) => t.addEventListener('click', () => this.goToSlide(i)));
this.slides.forEach((slide, index) => {
slide.addEventListener('click', (e) => {
if (e.target && e.target.tagName === 'IMG') this.openModal(index);
});
const vid = slide.querySelector('video');
if (vid) this._bindVideo(vid);
});
this.modal.querySelector('[data-modal-close]')?.addEventListener('click', () => this.closeModal());
this.modal.addEventListener('click', (e) => {
if (e.target.classList.contains('gallery-modal')) this.closeModal();
});
this.modal.querySelector('[data-modal-prev]')?.addEventListener('click', () => this.modalPrev());
this.modal.querySelector('[data-modal-next]')?.addEventListener('click', () => this.modalNext());
this.modal.querySelector('[data-modal-thumb-prev]')?.addEventListener('click', () => this.modalThumbPrev());
this.modal.querySelector('[data-modal-thumb-next]')?.addEventListener('click', () => this.modalThumbNext());
this.modalThumbnails.forEach((t, i) => t.addEventListener('click', () => this.goToModalSlide(i)));
this.modalSlides.forEach((slide) => {
const img = slide.querySelector('img');
if (img) {
slide.addEventListener('click', (e) => {
if (window.matchMedia('(max-width: 768px)').matches) return; // no click zoom on mobile
if (e.target === img || e.target === slide) this.toggleZoom(slide);
});
slide.addEventListener('mousemove', (e) => this.handleZoomMove(e));
slide.addEventListener('mouseleave', () => this.deactivateZoom(slide));
}
const vid = slide.querySelector('video');
if (vid) this._bindVideo(vid);
});
document.addEventListener('keydown', (e) => {
if (!this.modal.classList.contains('active')) return;
if (e.key === 'Escape') this.closeModal();
if (e.key === 'ArrowLeft') this.modalPrev();
if (e.key === 'ArrowRight') this.modalNext();
});
}
_bindVideo(vid) {
vid.controls = true;
vid.style.pointerEvents = 'auto';
vid.addEventListener('pointerdown', (e) => e.stopPropagation());
vid.addEventListener('click', (e) => e.stopPropagation());
}
setupSwipeGestures() {
const addSwipe = (el, prev, next) => {
if (!el) return;
let sx = 0, sy = 0, dx = 0, dy = 0;
el.addEventListener('touchstart', (e) => {
if (e.touches.length > 1) return;
sx = e.touches[0].clientX; sy = e.touches[0].clientY;
}, { passive: true });
el.addEventListener('touchmove', (e) => {
if (e.touches.length > 1 || !sx) return;
dx = e.touches[0].clientX - sx; dy = e.touches[0].clientY - sy;
}, { passive: true });
el.addEventListener('touchend', () => {
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 50) { dx > 0 ? prev() : next(); }
sx = sy = dx = dy = 0;
}, { passive: true });
};
addSwipe(this.gallerySlider, () => this.prevSlide(), () => this.nextSlide());
addSwipe(this.modalSliderViewport, () => this.modalPrev(), () => this.modalNext());
}
// Desktop zoom in modal
toggleZoom(slide) {
slide.classList.toggle('zoomed');
const img = slide.querySelector('img');
if (!img) return;
if (!slide.classList.contains('zoomed')) {
img.style.transform = '';
img.style.removeProperty('--zoom-scale');
return;
}
const zoomSrc = img.dataset.zoom || img.src;
const hi = new Image();
hi.onload = () => {
img.src = hi.src;
const base = Math.max(1, hi.naturalWidth / img.clientWidth);
const scale = Math.min(this.maxModalZoom, base);
img.style.setProperty('--zoom-scale', scale);
// modal uses vertical pan only by design
img.style.transform = `scale(${scale})`;
};
hi.src = zoomSrc;
}
handleZoomMove(e) {
const slide = e.currentTarget;
if (!slide.classList.contains('zoomed')) return;
const img = slide.querySelector('img');
const r = slide.getBoundingClientRect();
const scale = parseFloat(img.style.getPropertyValue('--zoom-scale')) || 1;
if (scale <= 1) return;
const my = e.clientY - r.top;
const pannableY = img.clientHeight * scale - r.height;
if (pannableY <= 0) return;
const ratioY = Math.min(Math.max(my / r.height, 0), 1);
const ty = -pannableY * ratioY;
img.style.transform = `scale(${scale}) translateY(${ty}px)`;
}
deactivateZoom(slide) {
if (!slide) return;
slide.classList.remove('zoomed');
const img = slide.querySelector('img');
if (img) { img.style.transform = ''; img.style.removeProperty('--zoom-scale'); }
}
/* ===== Main gallery ===== */
goToSlide(i) {
if (i < 0 || i >= this.totalSlides) return;
this.slides.forEach(s => s.classList.remove('active'));
this.dots.forEach(d => d.classList.remove('active'));
this.thumbnails.forEach(t => t.classList.remove('active'));
this.currentIndex = i;
this.slides[i]?.classList.add('active');
this.dots[i]?.classList.add('active');
this.thumbnails[i]?.classList.add('active');
}
nextSlide() { this.goToSlide((this.currentIndex + 1) % this.totalSlides); }
prevSlide() { this.goToSlide((this.currentIndex - 1 + this.totalSlides) % this.totalSlides); }
/* ===== Modal ===== */
openModal(i) {
this.triggerElement = document.activeElement;
this.modal.classList.add('active');
document.body.style.overflow = 'hidden';
this.goToModalSlide(i, true);
if (this.modalThumbsTrack) this.modalThumbsTrack.scrollLeft = 0;
this.updateThumbLayout();
}
closeModal() {
this.modal.classList.remove('active');
document.body.style.overflow = '';
this.deactivateZoom(this.modalSlides[this.currentIndex] || null);
this.modalSlides.forEach(s => {
const v = s.querySelector('video'); const f = s.querySelector('iframe');
if (v) v.pause();
if (f) { const src = f.src; f.src = ''; f.src = src; }
});
if (this.triggerElement) this.triggerElement.focus();
}
goToModalSlide(i, initial = false) {
if (i < 0 || i >= this.totalSlides) return;
this.deactivateZoom(this.modalSlides[this.currentIndex] || null);
this.currentIndex = i;
this.modalSliderTrack.style.transition = initial ? 'none' : 'transform .3s ease';
this.modalSliderTrack.style.transform = `translateX(-${i * 100}%)`;
this.modalThumbnails.forEach(t => t.classList.remove('active'));
this.modalThumbnails[i]?.classList.add('active');
const active = this.modalThumbnails[i];
if (active && this.modalThumbsTrack && this.modalThumbsViewport) {
const trackRect = this.modalThumbsTrack.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
const viewportRect = this.modalThumbsViewport.getBoundingClientRect();
if (activeRect.left < viewportRect.left) {
this.modalThumbsTrack.scrollBy({ left: activeRect.left - viewportRect.left - 10, behavior: 'smooth' });
} else if (activeRect.right > viewportRect.right) {
this.modalThumbsTrack.scrollBy({ left: activeRect.right - viewportRect.right + 10, behavior: 'smooth' });
}
}
if (!initial) setTimeout(() => { this.modalSliderTrack.style.transition = ''; }, 300);
}
modalNext() { this.goToModalSlide((this.currentIndex + 1) % this.totalSlides); }
modalPrev() { this.goToModalSlide((this.currentIndex - 1 + this.totalSlides) % this.totalSlides); }
modalThumbNext() { this.modalThumbsTrack?.scrollBy({ left: 120, behavior: 'smooth' }); }
modalThumbPrev() { this.modalThumbsTrack?.scrollBy({ left: -120, behavior: 'smooth' }); }
updateThumbLayout() {
if (!this.modalThumbsViewport || !this.modalThumbsTrack) return;
const canCenter = this.modalThumbsTrack.scrollWidth <= this.modalThumbsViewport.clientWidth + 1;
this.modalThumbsViewport.classList.toggle('is-centered', canCenter);
}
/* ===== PLP hover zoom, scale then translate with proper offsets ===== */
setupPlpHoverZoom() {
const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
const bindSlide = (slide) => {
const img = slide.querySelector('img');
if (!img) return;
let hi = null;
let scale = 1;
let baseW = 0, baseH = 0;
const enter = () => {
if (!isDesktop()) return;
const zoomSrc = img.dataset.zoom || img.src;
// measure displayed image size before zoom
const rect = img.getBoundingClientRect();
baseW = rect.width;
baseH = rect.height;
if (!hi) {
hi = new Image();
hi.onload = () => {
img.src = hi.src;
const computed = Math.max(1, hi.naturalWidth / baseW);
scale = Math.min(this.maxPlpZoom, computed);
slide.classList.add('plp-zoomed');
};
hi.src = zoomSrc;
} else {
const computed = Math.max(1, hi.naturalWidth / baseW);
scale = Math.min(this.maxPlpZoom, computed);
slide.classList.add('plp-zoomed');
}
};
const move = (e) => {
if (!isDesktop()) return;
if (!slide.classList.contains('plp-zoomed') || scale <= 1) return;
const sr = slide.getBoundingClientRect();
// image is centered in slide (object-fit: contain)
const offsetX = Math.max(0, (sr.width - baseW) / 2);
const offsetY = Math.max(0, (sr.height - baseH) / 2);
// pointer relative to the displayed image box
const clamp = (v, a, b) => Math.min(Math.max(v, a), b);
const x = clamp((e.clientX - (sr.left + offsetX)) / baseW, 0, 1);
const y = clamp((e.clientY - (sr.top + offsetY)) / baseH, 0, 1);
// total extra content introduced by scaling
const pannableX = baseW * scale - baseW;
const pannableY = baseH * scale - baseH;
// translate in image coordinates so scaling does not multiply translation
const tx = -(pannableX * x) / scale;
const ty = -(pannableY * y) / scale;
img.style.transform = `scale(${scale}) translate(${tx}px, ${ty}px)`;
};
const leaveAll = () => {
slide.classList.remove('plp-zoomed');
img.style.transform = '';
};
slide.addEventListener('mouseenter', enter);
slide.addEventListener('mousemove', move);
// cancel only when leaving the whole gallery area
this.gallerySlider.addEventListener('mouseleave', leaveAll);
};
this.slides.forEach(bindSlide);
window.addEventListener('resize', () => {
if (!isDesktop()) {
this.slides.forEach(s => {
s.classList.remove('plp-zoomed');
const img = s.querySelector('img');
if (img) img.style.transform = '';
});
}
}, { passive: true });
}
}
function initializeGalleries() {
document.querySelectorAll('[data-gallery]').forEach(g => new CustomProductGallery(g));
}
document.addEventListener('DOMContentLoaded', initializeGalleries);
document.addEventListener('shopify:section:load', initializeGalleries);
