/** * JustifiedGallery * * A responsive justified image gallery with smooth transitions, modal preview, * custom item styles, and dynamic hover effects. * * Features: * - Responsive columns & max row height (with breakpoints) * - Optional modal (zoomable, draggable, navigable) * - Configurable hover effects (Lens, Ripple, Spotlight, etc.) * - Custom per-item inline styles via `itemStyle` * - Smooth animated layout transitions when column count changes (FLIP via overlay) * * Author: Aranéite (Antoine) * Version: 2.4 */ export default class JustifiedGallery { /* ========================================================= * Constructor & public state * ======================================================= */ /** * @param {HTMLElement} container - The gallery container element. * @param {Object} options - Configuration options. */ constructor(container, options = {}) { if (!container) return; this.container = container; this.images = []; this.imagesClone = []; this.currentIndex = 0; this.isOpenModal = false; this.currentCols = null; // track current column count for breakpoint animation this.options = { margin: options.margin ?? 5, columns: options.columns ?? 5, breakpoints: options.breakpoints ?? { 1248: 3, 768: 2, 480: 1 }, hasModal: options.hasModal ?? false, linkAttribute: options.linkAttribute || 'src', justifyLastRow: options.justifyLastRow ?? false, hoverEffect: options.hoverEffect || null, itemStyle: options.itemStyle || {}, maxRowHeight: options.maxRowHeight ?? 350, maxRowHeightBreakpoints: options.maxRowHeightBreakpoints ?? { 1248: 300, 768: 250, 480: 200, }, }; // Initialisation this.init(); // Handle responsive relayout on resize this._onResize = () => { if (!this.container) return; const newCols = this._getColumns(); const shouldAnimateWrap = this.currentCols !== null && newCols !== this.currentCols; this.currentCols = newCols; this.layoutGallery({ animateWrap: shouldAnimateWrap }); }; window.addEventListener('resize', this._onResize); } /* ========================================================= * Initialization * ======================================================= */ /** * Initialize gallery: * - wrap images in anchors * - wait for load * - initial layout * - modal / hover / custom styles */ async init() { // Start hidden → fade-in after first layout this.container.style.opacity = '0'; // Initial raw images this.images = this._getImages(); this.imagesClone = this._getImagesClones(); // Wrap each image in a link () and attach hover class this.images.forEach(img => this._wrapImage(img)); // Refresh internal image list (now wrapped by ) this.images = this._getImages(); // Wait for all images to be loaded (or failed) const promises = this.images.map(img => this._waitForImageLoad(img)); await Promise.all(promises); // First layout (no animation on initial render) this.currentCols = this._getColumns(); this.layoutGallery({ animateWrap: false }); // Modal, hover effects, and custom item styles this.setupModal(); this.initHoverEffects(); this.applyItemStyles(); // Smooth fade-in requestAnimationFrame(() => { this.container.style.transition = 'opacity 0.6s ease'; this.container.style.opacity = '1'; }); } /** * Apply custom styles defined in options.itemStyle * to each gallery item (link wrapper). */ applyItemStyles() { this.images.forEach(img => { const wrapper = img.parentNode; for (const key in this.options.itemStyle) { wrapper.style[key] = this.options.itemStyle[key]; } }); } /** * Initialize hover effects (spotlight, ripple, lens...) * once DOM is ready and images are wrapped. */ initHoverEffects() { const effect = this.options.hoverEffect; // Spotlight effect (dynamic import) if (effect === 'hover-spotlight') { import('./effects/spotlight.js') .then(({ enableSpotlightEffect }) => { enableSpotlightEffect(this.container); }) } // Ripple effect: radial animation on mouse enter if (effect === 'hover-ripple-animate') { import('./effects/ripple-animate.js') .then(({ enableRippleEffect }) => { enableRippleEffect(this.container); }) } // Lens effect: zoomed circular region following the cursor if (effect === 'hover-lens') { import('./effects/lens.js') .then(({ enableLensEffect }) => { enableLensEffect(this.container); }) } } /** * Clean up listeners and DOM references. */ destroy() { window.removeEventListener('resize', this._onResize); if (this.modal && document.body.contains(this.modal)) { this.modal.remove(); } // Remove any listeners on images by cloning them if (this.images && this.images.length) { this.images.forEach(img => { const clone = img.cloneNode(true); img.replaceWith(clone); }); } this.container = null; this.images = null; this.modal = null; this.modalImg = null; this.closeBtn = null; this.prevBtn = null; this.nextBtn = null; this.isOpenModal = false; } /* ========================================================= * Layout computation & application * ======================================================= */ /** * Compute layout data for all rows without mutating the DOM. * * @returns {Array<{ item: HTMLElement, img: HTMLImageElement, * width: number, height: number, * marginRight: number, marginBottom: number }>} */ _computeLayout() { const containerWidth = this.container.clientWidth; const margin = this.options.margin; const cols = this._getColumns(); const maxRowHeight = this._getMaxRowHeight(); const justifyLastRow = this.options.justifyLastRow; const layout = []; let rowImages = []; let totalRatio = 0; this.images.forEach((img, index) => { const ratio = img.naturalWidth / img.naturalHeight || 1; const item = img.parentNode; rowImages.push({ item, img, ratio }); totalRatio += ratio; const isLastRow = index === this.images.length - 1; if (rowImages.length === cols || isLastRow) { let rowHeight; if (isLastRow && rowImages.length < cols && !justifyLastRow) { // Do not stretch last row: use average ratio and target less height const avgRatio = totalRatio / rowImages.length; rowHeight = Math.floor((containerWidth / cols) / avgRatio); rowHeight = Math.min(rowHeight, maxRowHeight); } else { // Normal justified row: fill container width rowHeight = Math.floor( (containerWidth - margin * (rowImages.length - 1)) / totalRatio ); rowHeight = Math.min(rowHeight, maxRowHeight); // Stretch to perfectly fill the row horizontally const totalWidth = rowImages.reduce((sum, r) => sum + rowHeight * r.ratio, 0) + margin * (rowImages.length - 1); if (totalWidth > 0) { const stretchFactor = containerWidth / totalWidth; rowHeight *= stretchFactor; } } rowImages.forEach((r, idx) => { const width = rowHeight * r.ratio; layout.push({ item: r.item, img: r.img, width, height: rowHeight, marginRight: idx < rowImages.length - 1 ? margin : 0, marginBottom: margin, }); }); rowImages = []; totalRatio = 0; } }); // Keep in sync with actual columns used this.currentCols = this._getColumns(); return layout; } /** * Apply computed layout to DOM elements (without animation). * @param {Array} layout - Layout entries from _computeLayout(). */ _applyLayout(layout) { layout.forEach(entry => { const { item, img, width, height, marginRight, marginBottom } = entry; // Anchor wrapper sized using computed layout item.style.width = `${width}px`; item.style.height = `${height}px`; item.style.display = 'inline-block'; item.style.marginRight = `${marginRight}px`; item.style.marginBottom = `${marginBottom}px`; item.style.transition = 'none'; // Image fills its wrapper img.style.width = '100%'; img.style.height = '100%'; img.style.cursor = 'pointer'; img.style.transition = 'none'; }); } /** * Main layout function. * - If animateWrap = true, uses overlay + FLIP-like animation * when the number of columns changes (breakpoint hit). * - Otherwise only recomputes justification without wrap animation. * * @param {Object} options * @param {boolean} options.animateWrap */ layoutGallery({ animateWrap = false } = {}) { if (!this.container || !this.images || !this.images.length) return; const layout = this._computeLayout(); // Simple re-justification when column count did not change if (!animateWrap) { this._applyLayout(layout); return; } /* --------------------------------------------- * Animated wrap change using fixed overlay + clones * ------------------------------------------- */ // 1) Capture current positions BEFORE applying new layout const prevRects = this._getWrapperRects(); const containerRect = this.container.getBoundingClientRect(); // 2) Create overlay (fixed) containing clones at old positions const overlay = document.createElement('div'); overlay.style.position = 'fixed'; overlay.style.left = `${containerRect.left}px`; overlay.style.top = `${containerRect.top}px`; overlay.style.width = `${containerRect.width}px`; overlay.style.height = `${containerRect.height}px`; overlay.style.pointerEvents = 'none'; overlay.style.zIndex = '9999'; const wrappers = this.images.map(img => img.parentNode); const clones = []; wrappers.forEach((wrapper, index) => { const r = prevRects[index]; if (!r) return; const clone = wrapper.cloneNode(true); // Position & size BEFORE layout change clone.style.position = 'absolute'; clone.style.left = `${r.left - containerRect.left}px`; clone.style.top = `${r.top - containerRect.top}px`; clone.style.width = `${r.width}px`; clone.style.height = `${r.height}px`; clone.style.margin = '0'; clone.style.boxSizing = 'border-box'; clone.style.transition = 'none'; clones.push(clone); overlay.appendChild(clone); }); document.body.appendChild(overlay); // Hide real items during animation to avoid flicker/misalignment wrappers.forEach(w => { w.style.opacity = '0'; }); // 3) Apply NEW layout (on the real gallery, but hidden) this._applyLayout(layout); // 4) Measure final positions AFTER layout const newRects = wrappers.map(w => w.getBoundingClientRect()); // 5) Animate clones from old to new rects requestAnimationFrame(() => { clones.forEach((clone, index) => { const newRect = newRects[index]; const final = layout[index]; // final width/height from layout if (!newRect || !final) return; clone.style.transition = 'all 0.6s cubic-bezier(0.25, 1, 0.5, 1)'; // Final position clone.style.left = `${newRect.left - containerRect.left}px`; clone.style.top = `${newRect.top - containerRect.top }px`; // Final size (should match real item => avoids jump) clone.style.width = `${newRect.width }px`; clone.style.height = `${newRect.height}px`; }); }); // 6) Cleanup: remove overlay & reveal real gallery setTimeout(() => { overlay.remove(); wrappers.forEach(w => { w.style.opacity = '1'; }); }, 650); } /* ========================================================= * Modal viewer * ======================================================= */ /** * Setup modal viewer if enabled. */ setupModal() { if (!this.options.hasModal) return; this.modal = document.createElement('div'); this.modal.classList.add('jg-modal'); this.modal.innerHTML = ` ×
`; document.body.appendChild(this.modal); this.modalImg = this.modal.querySelector('.jg-modal-img'); this.closeBtn = this.modal.querySelector('.jg-close'); this.prevBtn = this.modal.querySelector('.jg-prev'); this.nextBtn = this.modal.querySelector('.jg-next'); // Open modal on image click this.images.forEach((img, idx) => { img.addEventListener('click', e => { e.preventDefault(); this.openModal(idx); }); }); // Basic modal controls this.closeBtn.addEventListener('click', () => this.closeModal()); this.prevBtn.addEventListener('click', () => this.prevImage()); this.nextBtn.addEventListener('click', () => this.nextImage()); // Close when clicking outside image this.modal.addEventListener('click', e => { if (e.target === this.modal) this.closeModal(); }); // Keyboard navigation inside modal document.addEventListener('keydown', e => { if (this.modal?.style.display === 'flex') { if (e.key === 'ArrowLeft') this.prevImage(); if (e.key === 'ArrowRight') this.nextImage(); if (e.key === 'Escape') this.closeModal(); } }); this.initModalInteractions(); } /** * Initialize modal drag & zoom interactions. */ initModalInteractions() { this.modalImg.currentScale = 1; let isDragging = false; let startX = 0; let startY = 0; let translateX = 0; let translateY = 0; // Mouse drag (panning) this.modalImg.addEventListener('mousedown', e => { if (e.button !== 0) return; isDragging = true; startX = e.clientX; startY = e.clientY; this.modalImg.style.cursor = 'grabbing'; e.preventDefault(); }); this.modal.addEventListener('mousemove', e => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; startX = e.clientX; startY = e.clientY; translateX += dx; translateY += dy; this.modalImg.style.transform = `translate(${translateX}px, ${translateY}px) scale(${this.modalImg.currentScale})`; }); // Stop dragging on mouse up / leave ['mouseup', 'mouseleave'].forEach(event => { this.modal.addEventListener(event, () => { isDragging = false; this.modalImg.style.cursor = 'grab'; }); }); // Scroll to zoom this.modalImg.addEventListener('wheel', e => { e.preventDefault(); const delta = e.deltaY > 0 ? -0.1 : 0.1; this.modalImg.currentScale = Math.min( Math.max(this.modalImg.currentScale + delta, 0.5), 3 ); this.modalImg.style.transform = `translate(${translateX}px, ${translateY}px) scale(${this.modalImg.currentScale})`; }); } openModal(index) { this.isOpenModal = true; this.currentIndex = index; this.modal.style.display = 'flex'; this.modalImg.src = this.images[this.currentIndex].src; document.body.style.overflow = 'hidden'; } closeModal() { this.isOpenModal = false; this.modal.style.display = 'none'; document.body.style.overflow = ''; this.modalImg.currentScale = 1; this.modalImg.style.transform = 'translate(0px, 0px) scale(1)'; } prevImage() { this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length; this.modalImg.src = this.images[this.currentIndex].src; } nextImage() { this.currentIndex = (this.currentIndex + 1) % this.images.length; this.modalImg.src = this.images[this.currentIndex].src; } /* ========================================================= * Private helpers * ======================================================= */ /** * @returns {HTMLImageElement[]} - All images inside the container. * @private */ _getImages() { return Array.from(this.container.querySelectorAll('img')); } /** * @returns {HTMLElement[]} - All elements marked as `.jg-clone` (if any). * @private */ _getImagesClones() { return Array.from(this.container.querySelectorAll('.jg-clone')); } /** * Wrap an image inside an anchor and attach hover class. * @private */ _wrapImage(img) { const href = img.getAttribute(this.options.linkAttribute) || img.src; const a = document.createElement('a'); a.href = href; a.style.display = 'inline-block'; // Preserve caption if present a.dataset.caption = img.dataset.caption || ''; img.dataset.caption = ''; // Attach hover class if defined this._setHover(a); img.parentNode.insertBefore(a, img); a.appendChild(img); } /** * Attach a hover class on the wrapper if hoverEffect is configured. * @private */ _setHover(el) { if (this.options.hoverEffect) { el.classList.add(this.options.hoverEffect); } } /** * Return the current columns count according to container width & breakpoints. * @private */ _getColumns() { const width = this.container.clientWidth; let cols = this.options.columns; const breakpoints = Object .keys(this.options.breakpoints) .map(Number) .sort((a, b) => a - b); for (const bp of breakpoints) { if (width <= bp) { cols = this.options.breakpoints[bp]; break; } } return cols; } /** * Return the max row height according to container width & maxRowHeightBreakpoints. * @private */ _getMaxRowHeight() { const width = this.container.clientWidth; let maxHeight = this.options.maxRowHeight; const breakpoints = this.options.maxRowHeightBreakpoints; if (!breakpoints) return maxHeight; const sorted = Object .keys(breakpoints) .map(Number) .sort((a, b) => a - b); for (const bp of sorted) { if (width <= bp) { maxHeight = breakpoints[bp]; break; } } return maxHeight; } /** * Wait until an image is loaded (or fails) before resolving. * @private */ _waitForImageLoad(img) { return new Promise(resolve => { if (img.complete) { resolve(); } else { img.onload = () => resolve(); img.onerror = () => resolve(); } }); } /** * Get bounding rects of wrappers (anchors) before layout. * @returns {DOMRect[]} rects ordered by image index * @private */ _getWrapperRects() { return this.images.map(img => img.parentNode.getBoundingClientRect()); } }