♻️ - Suppression de la classe JustifiedGallery et ajout de nouveaux effets de survol pour les images

This commit is contained in:
2025-11-15 03:20:16 +01:00
parent 3080726c9b
commit 8f52f38ac5
9 changed files with 1617 additions and 390 deletions

View File

@@ -0,0 +1,864 @@
.hover-zoom {
overflow: hidden;
}
.hover-zoom img {
transition: transform 0.4s ease;
}
.hover-zoom:hover img {
transform: scale(1.05);
}
.hover-tilt {
overflow: hidden;
}
.hover-tilt img {
transition: transform 0.4s ease;
}
.hover-tilt:hover img {
transform: scale(1.04) rotate(1deg);
}
.hover-blur img {
filter: blur(2px);
transition: filter 0.3s ease;
}
.hover-blur:hover img {
filter: blur(0);
}
.hover-blur-invert img {
transition: filter 0.3s ease;
}
.hover-blur-invert:hover img {
filter: blur(2px);
}
.hover-darken img {
transition: filter 0.3s ease;
}
.hover-darken:hover img {
filter: brightness(0.85);
}
.hover-lighten img {
transition: filter 0.3s ease;
}
.hover-lighten:hover img {
filter: brightness(1.15);
}
.hover-lift {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-lift:hover {
transform: translateY(-4px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}
.hover-overlay {
position: relative;
overflow: hidden;
}
.hover-overlay::after {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0);
transition: background 0.3s ease;
}
.hover-overlay:hover::after {
background: rgba(0, 0, 0, 0.25);
}
.hover-border {
transition: box-shadow 0.3s ease;
}
.hover-border:hover {
box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.3);
}
.hover-colorize img {
filter: grayscale(100%);
transition: filter 0.4s ease;
}
.hover-colorize:hover img {
filter: grayscale(0%);
}
.hover-slide {
position: relative;
overflow: hidden;
}
.hover-slide::after {
content: attr(data-caption);
position: absolute;
bottom: -100%;
left: 0;
width: 100%;
background: rgba(0,0,0,0.6);
color: white;
text-align: center;
padding: 0.5em;
font-size: 0.9em;
transition: bottom 0.3s ease;
}
.hover-slide:hover::after {
bottom: 0;
}
.hover-dezoom img {
transition: transform 0.4s ease;
}
.hover-dezoom:hover img {
transform: scale(.95);
}
.hover-slide-left img, .hover-slide-right img {
transition: transform 0.3s ease;
}
.hover-slide-left:hover img {
transform: translateX(-5px);
}
.hover-slide-right:hover img {
transform: translateX(5px);
}
.hover-fade-zoom img {
opacity: 0.9;
transform: scale(1);
transition: transform 0.5s ease, opacity 0.5s ease;
}
.hover-fade-zoom:hover img {
opacity: 1;
transform: scale(1.02);
}
.hover-frosted img {
transition: filter 0.3s ease;
}
.hover-frosted:hover img {
filter: blur(1px) brightness(1.2);
}
.hover-underline {
position: relative;
overflow: hidden;
}
.hover-underline::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 4px;
background: #007cba;
transition: all 0.3s ease;
}
.hover-underline:hover::after {
left: 0;
width: 100%;
}
.hover-desaturate img {
filter: grayscale(0%);
transition: filter 0.4s ease;
}
.hover-desaturate:hover img {
filter: grayscale(100%);
}
.hover-glow {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-glow:hover {
transform: translateY(-3px);
box-shadow: 0 6px 15px rgba(255, 225, 0, 0.3);
}
.hover-polaroid {
background: white;
padding: 4px;
border: 1px solid #eee;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-polaroid:hover {
transform: translateY(-5px) rotate(-1deg);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
}
.hover-caption {
position: relative;
overflow: hidden;
}
.hover-caption::after {
content: attr(data-caption);
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.5);
color: white;
font-size: 1.1em;
opacity: 0;
transition: opacity 0.3s ease;
}
.hover-caption:hover::after {
opacity: 1;
}
.hover-zoom-overlay {
position: relative;
overflow: hidden;
}
.hover-zoom-overlay img {
transition: transform 0.4s ease;
}
.hover-zoom-overlay::after {
content: '';
position: absolute;
inset: 0;
background: rgba(0,0,0,0);
transition: background 0.3s ease;
}
.hover-zoom-overlay:hover img {
transform: scale(1.1);
}
.hover-zoom-overlay:hover::after {
background: rgba(0,0,0,0.25);
}
.hover-fadeout img {
transition: opacity 0.4s ease;
}
.hover-fadeout:hover img {
opacity: 0.7;
}
.hover-wiggle:hover img {
animation: wiggle 0.4s ease-in-out;
}
@keyframes wiggle {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(0.8deg); }
75% { transform: rotate(-0.8deg); }
}
.hover-rotateY img {
transition: transform 0.6s ease;
transform-style: preserve-3d;
}
.hover-rotateY:hover img {
transform: rotateY(8deg);
}
.hover-shine {
position: relative;
overflow: hidden;
}
.hover-shine::before {
content: '';
position: absolute;
top: 0;
left: -75%;
width: 50%;
height: 100%;
background: linear-gradient(
120deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0) 100%
);
transform: skewX(-20deg);
transition: left 0.75s ease;
}
.hover-shine:hover::before {
left: 125%;
}
.hover-mask {
position: relative;
overflow: hidden;
}
.hover-mask::after {
content: '';
position: absolute;
inset: 0;
background: rgba(0,0,0,0);
transform: translateY(100%);
transition: transform 0.3s ease, background 0.3s ease;
}
.hover-mask:hover::after {
transform: translateY(0);
background: rgba(0,0,0,0.25);
}
.hover-vignette img {
transition: filter 0.4s ease;
}
.hover-vignette:hover img {
filter: brightness(1.1) contrast(1.05) saturate(1.1) drop-shadow(0 0 15px rgba(0,0,0,0.3));
}
.hover-halo {
position: relative;
overflow: hidden;
}
.hover-halo::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle, rgba(255,255,255,0) 40%, rgba(255,255,255,0.3) 100%);
opacity: 0;
transition: opacity 0.4s ease;
}
.hover-halo:hover::after {
opacity: 1;
}
.hover-lift-up {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-lift-up:hover {
transform: translateY(-8px);
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
}
.hover-outline-glow {
position: relative;
transition: box-shadow 0.4s ease;
}
.hover-outline-glow:hover {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.8), 0 0 15px rgba(0, 150, 255, 0.3);
}
.hover-tint {
position: relative;
overflow: hidden;
}
.hover-tint::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(45deg, rgba(0,123,255,0) 0%, rgba(0,123,255,0.25) 100%);
opacity: 0;
transition: opacity 0.4s ease;
}
.hover-tint:hover::after {
opacity: 1;
}
.hover-grain img {
transition: filter 0.3s ease;
}
.hover-grain:hover img {
filter: contrast(1.1) brightness(1.05) saturate(1.05);
background-blend-mode: multiply;
}
.hover-slide-up {
position: relative;
overflow: hidden;
}
.hover-slide-up img {
transition: transform 0.4s ease;
}
.hover-slide-up:hover img {
transform: translateY(-10px);
}
.hover-frame {
position: relative;
}
.hover-frame::before,
.hover-frame::after {
content: '';
position: absolute;
background: #fff;
transition: transform 0.3s ease;
}
.hover-parallax {
overflow: hidden;
perspective: 700px;
}
.hover-parallax img {
transition: transform 0.3s ease;
transform-origin: center;
}
.hover-parallax:hover img {
transform: rotateY(3deg) rotateX(2deg) scale(1.03);
}
.hover-ripple {
position: relative;
overflow: hidden;
}
.hover-ripple::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: radial-gradient(circle, rgba(255,255,255,0.4) 0%, transparent 70%);
transform: translate(-50%, -50%);
transition: width 0.5s ease, height 0.5s ease, opacity 0.6s ease;
opacity: 0;
}
.hover-ripple:hover::after {
width: 200%;
height: 200%;
opacity: 1;
}
.hover-glass {
position: relative;
overflow: hidden;
}
.hover-glass::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(120deg, rgba(255,255,255,0.3), rgba(255,255,255,0) 70%);
mix-blend-mode: overlay;
opacity: 0;
transition: opacity 0.4s ease;
}
.hover-glass:hover::after {
opacity: 1;
}
.hover-window {
position: relative;
overflow: hidden;
}
.hover-window img {
transition: transform 0.4s ease, clip-path 0.4s ease;
clip-path: inset(0);
}
.hover-window:hover img {
clip-path: inset(5% 5%);
transform: scale(1.1);
}
.hover-diagonal {
position: relative;
overflow: hidden;
}
.hover-diagonal::before {
content: '';
position: absolute;
top: -100%;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.3);
transform: skewY(-10deg);
transition: top 0.4s ease;
}
.hover-diagonal:hover::before {
top: 0;
}
.hover-analog img {
transition: filter 0.4s ease;
}
.hover-analog:hover img {
filter: contrast(1.15) brightness(1.05) saturate(1.1) sepia(0.1);
}
.hover-shutter {
position: relative;
overflow: hidden;
background: #000;
}
.hover-shutter img {
transition: clip-path 0.6s ease;
clip-path: circle(80% at center);
}
.hover-shutter:hover img {
clip-path: circle(35% at center);
}
.hover-mist {
position: relative;
overflow: hidden;
}
.hover-mist::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.2), transparent 70%);
opacity: 0;
transition: opacity 0.6s ease;
}
.hover-mist:hover::after {
opacity: 1;
}
.hover-sunset img {
transition: filter 0.4s ease;
}
.hover-sunset:hover img {
filter: sepia(0.3) saturate(1.2) hue-rotate(-10deg);
}
.hover-spotlight {
position: relative;
overflow: hidden;
}
.hover-spotlight img {
display: block;
transition: transform 0.3s ease;
}
.hover-spotlight:hover img {
transform: scale(1.03);
}
.hover-spotlight .spotlight-overlay {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(
circle at center,
rgba(255, 255, 255, 0),
rgba(0, 0, 0, 0.4) 70%
);
transition: background 0.3s ease;
}
.hover-fracture {
position: relative;
overflow: hidden;
}
.hover-fracture img {
transition: transform 0.4s ease;
}
.hover-fracture::after {
content: '';
position: absolute;
inset: 0;
background: inherit;
background-attachment: fixed;
mix-blend-mode: screen;
transform: translate(5px, -5px);
opacity: 0;
transition: all 0.4s ease;
}
.hover-fracture:hover img {
transform: translate(-3px, 3px);
}
.hover-fracture:hover::after {
opacity: 0.6;
}
.hover-morph img {
border-radius: 12px;
transition: border-radius 0.6s ease, transform 0.6s ease;
}
.hover-morph:hover img {
border-radius: 40% 60% 50% 70% / 60% 50% 70% 40%;
}
.hover-grid {
position: relative;
overflow: hidden;
}
.hover-grid::after {
content: '';
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(90deg, rgba(255,255,255,0.05) 0, rgba(255,255,255,0.05) 1px, transparent 1px, transparent 10px),
repeating-linear-gradient(0deg, rgba(255,255,255,0.05) 0, rgba(255,255,255,0.05) 1px, transparent 1px, transparent 10px);
opacity: 0;
transition: opacity 0.4s ease;
}
.hover-grid:hover::after {
opacity: 1;
animation: gridMove 3s linear infinite;
}
@keyframes gridMove {
to { background-position: 40px 40px; }
}
.hover-freeze {
position: relative;
overflow: hidden;
}
.hover-freeze img {
transition: filter 0.5s ease;
}
.hover-freeze:hover img {
filter: grayscale(.7) brightness(1.2) sepia(0.2);
}
.hover-freeze::after {
content: '';
position: absolute;
inset: 0;
border: 2px solid rgba(255,255,255,0);
transition: border-color 0.5s ease;
}
.hover-freeze:hover::after {
border-color: rgba(255,255,255,0.8);
}
.hover-pixelate img {
transition: filter 0.4s ease;
}
.hover-pixelate:hover img {
image-rendering: pixelated;
filter: contrast(1.1);
}
.hover-waveflow img {
transform-origin: center;
animation: none;
}
.hover-waveflow:hover img {
animation: waveflow 1.2s ease-in-out infinite alternate;
}
@keyframes waveflow {
0% { transform: rotate(0.4deg) translateY(1px); }
100% { transform: rotate(-0.4deg) translateY(-1px); }
}
.hover-prism img {
transition: filter 0.3s ease;
}
.hover-prism:hover img {
filter: contrast(1.1) saturate(1.2) drop-shadow(2px 0 red) drop-shadow(-2px 0 cyan);
}
.hover-vibrate:hover img {
animation: vibrate 0.5s linear infinite;
}
@keyframes vibrate {
0% { transform: translate(0); }
25% { transform: translate(-1px, 1px); }
50% { transform: translate(1px, -1px); }
75% { transform: translate(-1px, -1px); }
100% { transform: translate(1px, 1px); }
}
.hover-scan {
position: relative;
overflow: hidden;
}
.hover-scan::before {
content: "";
position: absolute;
top: -100%;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.25) 50%, transparent 100%);
transform: translateY(-100%);
}
.hover-scan:hover::before {
animation: scanline 1s linear;
}
@keyframes scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(200%); }
}
.hover-glitch img {
transition: transform 0.1s;
}
.hover-glitch:hover img {
animation: glitch .3s steps(2, end) infinite;
}
@keyframes glitch {
0% { clip-path: inset(10% 0 90% 0); transform: translate(2px); }
20% { clip-path: inset(80% 0 10% 0); transform: translate(-2px); }
40% { clip-path: inset(30% 0 60% 0); transform: translate(1px, -1px); }
60% { clip-path: inset(50% 0 30% 0); transform: translate(-1px, 1px); }
80% { clip-path: inset(40% 0 40% 0); transform: translate(0); }
100% { clip-path: inset(0 0 0 0); transform: translate(0); }
}
.hover-tilt3d {
transform-style: preserve-3d;
transition: transform 0.2s ease;
}
.hover-twist img {
transition: transform 0.6s ease;
transform-origin: center;
}
.hover-twist:hover img {
transform: perspective(1300px) rotateY(10deg);
}
.hover-fog {
position: relative;
overflow: hidden;
}
.hover-fog::after {
content: "";
position: absolute;
inset: 0;
background: url('https://assets.codepen.io/13471/fog.png') repeat;
mix-blend-mode: screen;
opacity: 0;
animation: fog-move 10s linear infinite;
transition: opacity 0.6s ease;
}
.hover-fog:hover::after {
opacity: 0.5;
}
@keyframes fog-move {
from { background-position: 0 0; }
to { background-position: 1000px 0; }
}
.hover-ripple-animate {
position: relative;
overflow: hidden;
display: inline-block;
}
.hover-ripple-animate::after {
content: "";
position: absolute;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255,255,255,0.3);
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
}
.ripple-effect {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%) scale(0);
animation: ripple-wave 0.6s ease-out forwards;
pointer-events: none;
}
@keyframes ripple-wave {
to {
transform: translate(-50%, -50%) scale(4);
opacity: 0;
}
}
.hover-lens {
position: relative;
display: inline-block;
overflow: hidden;
cursor: none;
}
.hover-lens img {
transition: filter 0.4s ease;
cursor: none!important;
}
.hover-lens:hover img {
filter: brightness(0.6) blur(2px);
}
.hover-lens:hover::after {
opacity: 1;
}
.hover-lens::after {
content: "";
position: absolute;
width: 170px;
height: 170px;
top: calc(var(--lens-y, 50px) - 60px);
left: calc(var(--lens-x, 50px) - 60px);
border-radius: 50%;
background-image: var(--lens-bg);
background-repeat: no-repeat;
background-size: 250%;
background-position: var(--lens-pos);
opacity: var(--hover, 0);
transition: opacity 0.2s ease;
pointer-events: none;
}

View File

@@ -1,10 +1,11 @@
import JustifiedGallery from "./lib/gallery.js";
import JustifiedGallery from "./lib/gallery/gallery.js";
document.addEventListener("DOMContentLoaded", () => {
let element = document.getElementById("gallery")
new JustifiedGallery(element, {
wrapLinks: true,
hoverEffect: "hover-spotlight",
});
})

View File

@@ -1,387 +0,0 @@
export default class JustifiedGallery {
constructor(container, options = {}) {
this.container = container;
this.options = {
margin: options.margin || 5,
columns: options.columns || 3,
breakpoints: options.breakpoints || { 768: 2, 480: 1 },
wrapLinks: options.wrapLinks || false, // active l'encapsulation
linkAttribute: options.linkAttribute || 'src', // attribut à utiliser pour href
justifyLastRow: options.justifyLastRow || false,
};
this.currentIndex = 0;
this.isOpenModal = false;
this.init();
window.addEventListener('resize', () => this.layoutGallery());
}
init() {
this.images = this.getImages();
// Encapsule les images si nécessaire
if (this.options.wrapLinks) {
this.images.forEach(img => this.wrapImage(img));
this.images = this.getImages(); // rafraîchit la liste
}
// S'assure que toutes les images sont chargées
const promises = this.images.map(img => this.waitForImageLoad(img));
Promise.all(promises).then(() => {
this.layoutGallery();
this.setupModal();
});
this.container.addEventListener('keydown', e => {
e.preventDefault();
if (e.key === 'ArrowRight') {
e.target.nextSibling.nextSibling.focus()
}
if (e.key === "ArrowLeft") {
e.target.previousSibling.previousSibling.focus()
}
if (e.key === "ArrowDown") {
const images = this.getImages();
let target = e.target;
for (let i = 0; i < this.options.columns; i++) {
if (Math.ceil(images.length / this.options.columns) <= i) {
// On récupère le dernier élément si on dépasse la dernière ligne
target = images[images.length - 1].parentNode;
break;
}
// Utiliser nextElementSibling pour éviter les text nodes
if (target.nextElementSibling) {
target = target.nextElementSibling;
}
}
if (target) {
target.focus();
// Scroll pour voir la ligne où se trouve l'image focus
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
if (e.key === "ArrowUp") {
let target = e.target
for (let i = 0; this.options.columns > i; i++) {
if (this.getImages().length < i) {
target = this.getImages()[0];
break;
}
target = target.previousSibling.previousSibling
}
if (target) {
target.focus();
// Scroll pour voir la ligne où se trouve l'image focus
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
if (e.key === "Enter") {
e.target.click()
}
})
}
wrapImage(img) {
const href = img.getAttribute(this.options.linkAttribute) || img.src;
const a = document.createElement('a');
a.href = href;
a.style.display = 'inline-block';
img.parentNode.insertBefore(a, img);
a.appendChild(img);
}
getImages() {
return Array.from(this.container.querySelectorAll('img'));
}
waitForImageLoad(img) {
return new Promise(resolve => {
if (img.complete) resolve();
else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
});
}
getColumns() {
const width = this.container.clientWidth;
let cols = this.options.columns;
for (let bp in this.options.breakpoints) {
if (width <= bp) cols = this.options.breakpoints[bp];
}
return cols;
}
layoutGallery() {
const containerWidth = this.container.clientWidth;
const margin = this.options.margin;
const cols = this.getColumns();
let rowImages = [];
let totalRatio = 0;
this.images.forEach((img, index) => {
const ratio = img.naturalWidth / img.naturalHeight;
if (this.options.wrapLinks) {
rowImages.push({ img: img.parentNode, ratio });
} else {
rowImages.push({ img, ratio });
}
totalRatio += ratio;
const isLastRow = index === this.images.length - 1;
if (rowImages.length === cols || isLastRow) {
let rowHeight;
if (isLastRow && rowImages.length < cols && !this.options.justifyLastRow) {
// Ne pas étirer la dernière ligne
const avgRatio = totalRatio / rowImages.length;
rowHeight = Math.floor((containerWidth / cols) / avgRatio);
} else {
// Étire normalement pour remplir la ligne
rowHeight = Math.floor((containerWidth - margin * (rowImages.length - 1)) / totalRatio);
}
this.scaleRow(rowImages, rowHeight, margin);
rowImages = [];
totalRatio = 0;
}
});
}
scaleRow(row, rowHeight, margin) {
row.forEach((item, index) => {
const width = rowHeight * item.ratio;
if (this.options.wrapLinks) {
item.img.style.overflow = 'hidden';
item.img.firstChild.style.width = '100%';
}
item.img.style.width = `${width}px`;
item.img.style.height = `${rowHeight}px`;
item.img.style.marginRight = index < row.length - 1 ? `${margin}px` : '0';
item.img.style.marginBottom = `${margin}px`;
item.img.style.cursor = 'pointer';
});
}
setupModal() {
// Création du modal
this.modal = document.createElement('div');
this.modal.classList.add('jg-modal');
this.modal.innerHTML = `
<span class="jg-close">&times;</span>
<img class="jg-modal-img" src="">
<div class="jg-controls">
<span class="jg-prev">&#10094;</span>
<span class="jg-next">&#10095;</span>
</div>
`;
document.body.appendChild(this.modal);
// Références
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');
// Événements
this.images.forEach((img, idx) => {
img.addEventListener('click', e => {
e.preventDefault(); // empêche le clic sur le lien
this.openModal(idx);
});
img.parentNode.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
this.openModal(idx);
}
})
});
this.closeBtn.addEventListener('click', () => this.closeModal());
this.prevBtn.addEventListener('click', () => this.prevImage());
this.nextBtn.addEventListener('click', () => this.nextImage());
this.modal.addEventListener('click', e => {
if (e.target === this.modal) this.closeModal();
});
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();
}
});
// Variables pour drag + zoom
this.modalImg.currentScale = 1;
let isDragging = false;
let startX, startY;
let currentTranslateX = 0, currentTranslateY = 0;
// Drag start
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();
});
// Drag move
this.modal.addEventListener('mousemove', e => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
startX = e.clientX;
startY = e.clientY;
currentTranslateX += dx;
currentTranslateY += dy;
this.modalImg.style.transform = `translate(${currentTranslateX}px, ${currentTranslateY}px) scale(${this.modalImg.currentScale})`;
});
// Drag end
this.modal.addEventListener('mouseup', () => {
isDragging = false;
this.modalImg.style.cursor = 'grab';
});
this.modal.addEventListener('mouseleave', () => {
isDragging = false;
this.modalImg.style.cursor = 'grab';
});
// Scroll pour zoom
this.modalImg.addEventListener('wheel', e => {
e.preventDefault();
const scaleAmount = e.deltaY > 0 ? -0.1 : 0.1;
this.modalImg.currentScale = Math.min(Math.max(this.modalImg.currentScale + scaleAmount, 0.5), 3); // limites min/max
this.modalImg.style.transform = `translate(${currentTranslateX}px, ${currentTranslateY}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 = 'scroll';
// Reset zoom et position
this.modalImg.currentScale = 1;
this.modalImg.style.transform = `translate(0px, 0px) scale(1)`;
currentTranslateX = 0;
currentTranslateY = 0;
}
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;
}
}
class Gallery {
constructor(container, options = {}) {
this.container = container;
this.options = {
margin: options.margin || 5,
columns: options.columns || 4, // nombre de colonnes par défaut
breakpoints: options.breakpoints || { 768: 2, 480: 1 }, // largeur => colonnes
};
this.init();
window.addEventListener('resize', () => this.layoutGallery());
}
init() {
this.images = this.getImages();
// On attend que toutes les images soient chargées pour avoir leurs dimensions naturelles
const promises = this.images.map(img => this.waitForImageLoad(img));
Promise.all(promises).then(() => this.layoutGallery());
}
getImages() {
return Array.from(this.container.querySelectorAll('img'));
}
waitForImageLoad(img) {
return new Promise(resolve => {
if (img.complete) resolve();
else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
});
}
calculateRatio(img) {
return img.naturalWidth / img.naturalHeight;
}
getColumns() {
const width = this.container.clientWidth;
let cols = this.options.columns;
// Vérifie les breakpoints pour adapter le nombre de colonnes
for (let bp in this.options.breakpoints) {
if (width <= bp) {
cols = this.options.breakpoints[bp];
}
}
return cols;
}
layoutGallery() {
const containerWidth = this.container.clientWidth;
const margin = this.options.margin;
const cols = this.getColumns();
const rowHeight = (containerWidth - margin * (cols - 1)) / cols; // largeur d'une "colonne" comme base
let rowImages = [];
this.images.forEach((img, index) => {
const ratio = this.calculateRatio(img);
rowImages.push({ img, ratio });
if (rowImages.length === cols || index === this.images.length - 1) {
this.scaleRow(rowImages, rowHeight, margin);
rowImages = [];
}
});
}
scaleRow(row, rowHeight, margin) {
row.forEach((item, index) => {
const width = rowHeight * item.ratio;
item.img.style.width = `${width}px`;
item.img.style.height = `${rowHeight}px`;
item.img.style.marginRight = index < row.length - 1 ? `${margin}px` : '0';
item.img.style.marginBottom = `${margin}px`;
});
}
}

View File

@@ -0,0 +1,37 @@
export default function enableLensEffect(container) {
container
.querySelectorAll('.hover-lens')
.forEach(link => {
const img = link.querySelector('img');
if (!img) return;
link.addEventListener('mousemove', e => {
const rect = link.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
link.style.setProperty('--lens-x', `${x}px`);
link.style.setProperty('--lens-y', `${y}px`);
link.style.setProperty('--lens-bg', `url(${img.src})`);
link.style.setProperty(
'--lens-pos',
`${(x / rect.width) * 100}% ${(y / rect.height) * 100}%`
);
link.style.backgroundImage = `url(${img.src})`;
});
link.addEventListener('mouseenter', () => {
const rect = link.getBoundingClientRect();
link.style.setProperty('--lens-bg', `url(${img.src})`);
link.style.setProperty('--lens-pos', `center center`);
link.style.setProperty('--lens-x', `${rect.width / 2}px`);
link.style.setProperty('--lens-y', `${rect.height / 2}px`);
link.classList.add('active');
});
link.addEventListener('mouseleave', () => {
link.classList.remove('active');
link.style.removeProperty('background-image');
});
});
}

View File

@@ -0,0 +1,19 @@
export default function enableRippleEffect(container) {
container
.querySelectorAll('.hover-ripple-animate')
.forEach(link => {
link.addEventListener('mouseenter', e => {
const rect = link.getBoundingClientRect();
const ripple = document.createElement('span');
ripple.classList.add('ripple-effect');
const size = Math.max(rect.width, rect.height);
ripple.style.width = ripple.style.height = `${size}px`;
ripple.style.left = `${e.clientX - rect.left}px`;
ripple.style.top = `${e.clientY - rect.top}px`;
link.appendChild(ripple);
ripple.addEventListener('animationend', () => ripple.remove());
});
});
}

View File

@@ -0,0 +1,33 @@
export function enableSpotlightEffect(container) {
const images = container.querySelectorAll('.hover-spotlight');
images.forEach(image => {
// Crée un overlay lumineux via pseudo ou dataset
const overlay = document.createElement('div');
overlay.classList.add('spotlight-overlay');
image.appendChild(overlay);
// Mise à jour dynamique du gradient
image.addEventListener('mousemove', (e) => {
const rect = image.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const intensity = Math.min(rect.width, rect.height) / 400;
overlay.style.background = `radial-gradient(
circle at ${x}px ${y}px,
rgba(255,255,255,${0.1 * intensity}),
rgba(0,0,0,0.5) 60%
)`;
});
// Reset progressif quand la souris sort
image.addEventListener('mouseleave', () => {
overlay.style.background = `radial-gradient(
circle at center,
rgba(255,255,255,0),
rgba(0,0,0,0.4) 70%
)`;
});
});
}

View File

@@ -0,0 +1,659 @@
/**
* 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">&times;</span>
<img class="jg-modal-img" src="">
<div class="jg-controls">
<span class="jg-prev">&#10094;</span>
<span class="jg-next">&#10095;</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());
}
}

View File

@@ -8,7 +8,8 @@
<title>Test de création d'une gallerie justifié</title>
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="assets/css/lib/gallery.css">
<link rel="stylesheet" href="assets/css/lib/gallery/gallery.css">
<link rel="stylesheet" href="assets/css/lib/gallery/hover.css">
<script src="assets/js/index.js" type="module" defer></script>
</head>
@@ -22,6 +23,6 @@
<img src="assets/img/PANA6308.jpg" alt="">
<img src="assets/img/PANA4649_1.jpg" alt="">
<img src="assets/img/PANA7846.jpg" alt="">
</di
</div>
</body>
</html>