660 lines
21 KiB
JavaScript
660 lines
21 KiB
JavaScript
/**
|
|
* 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 (<a>) and attach hover class
|
|
this.images.forEach(img => this._wrapImage(img));
|
|
|
|
// Refresh internal image list (now wrapped by <a>)
|
|
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 <a> 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 = `
|
|
<span class="jg-close">×</span>
|
|
<img class="jg-modal-img" src="">
|
|
<div class="jg-controls">
|
|
<span class="jg-prev">❮</span>
|
|
<span class="jg-next">❯</span>
|
|
</div>
|
|
`;
|
|
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());
|
|
}
|
|
}
|