Files
livetrail/src/scripts/lightbox.js
T
2026-04-25 19:07:51 +02:00

568 lines
19 KiB
JavaScript

const defaults = {
albumLabel: 'Image %1 of %2',
alwaysShowNavOnTouchDevices: false,
fadeDuration: 600,
fitImagesInViewport: true,
imageFadeDuration: 600,
positionFromTop: 50,
resizeDuration: 700,
wrapAround: false,
disableScrolling: false,
sanitizeTitle: false,
hasVideo: true,
onMediaChange: () => {},
onClosing: () => {}
};
class Lightbox {
constructor() {
this.album = [];
this.currentImageIndex = 0;
this.options = { ...defaults };
this.gMouseDownOffsetX = 0;
this.gMouseDownOffsetY = 0;
this.resizeTimer = null;
this.boundOnBodyClick = this.onBodyClick.bind(this);
this.boundOnResize = this.sizeOverlay.bind(this);
this.boundOnKeyUp = this.keyboardAction.bind(this);
this.boundOnWheel = this.onWheel.bind(this);
this.boundOnDragStart = this.onDragStart.bind(this);
this.boundOnDragMove = this.onDragMove.bind(this);
this.boundOnDragEnd = this.onDragEnd.bind(this);
this.init();
}
option(options = {}) {
Object.assign(this.options, options);
}
init() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
this.enable();
this.build();
}, { once: true });
} else {
this.enable();
this.build();
}
}
enable() {
document.body.addEventListener('click', this.boundOnBodyClick);
}
onBodyClick(event) {
const link = event.target.closest('a[rel^="lightbox"], area[rel^="lightbox"], a[data-lightbox], area[data-lightbox]');
if (!link) return;
event.preventDefault();
this.start(link);
}
build() {
if (document.getElementById('lightbox')) return;
const wrapper = document.createElement('div');
wrapper.innerHTML = `
<div id="lightboxOverlay" tabindex="-1" class="lightboxOverlay"></div>
<div id="lightbox" tabindex="-1" class="lightbox">
<div class="lb-outerContainer">
<div class="lb-container">
<img class="lb-image" src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" alt="" />
<div class="lb-nav">
<div class="lb-prev-area">
<a class="lb-prev" aria-label="Previous image" href="" role="button"></a>
</div>
<div class="lb-next-area">
<a class="lb-next" aria-label="Next image" href="" role="button"></a>
</div>
</div>
<div class="lb-loader">
<a class="lb-cancel" href="#"></a>
</div>
</div>
</div>
<div class="lb-dataContainer desktop">
<div class="lb-data">
<div class="lb-details">
<span class="lb-caption"></span>
</div>
<div class="lb-closeContainer">
<a class="lb-close" role="button"></a>
</div>
</div>
</div>
</div>
`;
document.body.append(...wrapper.children);
this.overlay = document.getElementById('lightboxOverlay');
this.lightbox = document.getElementById('lightbox');
this.outerContainer = this.lightbox.querySelector('.lb-outerContainer');
this.container = this.lightbox.querySelector('.lb-container');
this.image = this.lightbox.querySelector('.lb-image');
this.nav = this.lightbox.querySelector('.lb-nav');
this.loader = this.lightbox.querySelector('.lb-loader');
this.caption = this.lightbox.querySelector('.lb-caption');
this.closeButton = this.lightbox.querySelector('.lb-close');
this.dataContainer = this.lightbox.querySelector('.lb-dataContainer');
this.prev = this.lightbox.querySelector('.lb-prev');
this.next = this.lightbox.querySelector('.lb-next');
this.video = document.createElement('video');
this.video.className = 'lb-video';
this.video.controls = true;
this.video.autoplay = true;
this.image.insertAdjacentElement('afterend', this.video);
this.overlay.style.display = 'none';
this.lightbox.style.display = 'none';
this.containerPadding = this.getBoxMetrics(this.container, 'padding');
this.imageBorderWidth = this.getBoxMetrics(this.image, 'border');
this.videoBorderWidth = this.getBoxMetrics(this.video, 'border');
this.overlay.addEventListener('click', () => this.end());
this.dataContainer.addEventListener('click', () => this.end());
this.lightbox.addEventListener('click', (event) => {
if (event.target === this.lightbox) this.end();
});
this.outerContainer.addEventListener('click', (event) => {
if (event.target === this.outerContainer) this.end();
event.stopPropagation();
});
this.prev.addEventListener('click', (event) => {
event.preventDefault();
if (this.currentImageIndex === 0) this.changeImage(this.album.length - 1);
else this.changeImage(this.currentImageIndex - 1);
});
this.next.addEventListener('click', (event) => {
event.preventDefault();
if (this.currentImageIndex === this.album.length - 1) this.changeImage(0);
else this.changeImage(this.currentImageIndex + 1);
});
this.loader.addEventListener('click', (event) => {
event.preventDefault();
this.end();
});
this.closeButton.addEventListener('click', (event) => {
event.preventDefault();
this.end();
});
this.closeButton.addEventListener('keyup', (event) => {
if (event.key === 'Enter' || event.key === ' ') this.end();
});
this.nav.addEventListener('wheel', this.boundOnWheel, { passive: false });
this.nav.addEventListener('mousedown', this.boundOnDragStart);
window.addEventListener('mouseup', this.boundOnDragEnd);
}
getBoxMetrics(element, type) {
const styles = getComputedStyle(element);
return {
top: parseInt(styles[`${type}-top-width`], 10) || 0,
right: parseInt(styles[`${type}-right-width`], 10) || 0,
bottom: parseInt(styles[`${type}-bottom-width`], 10) || 0,
left: parseInt(styles[`${type}-left-width`], 10) || 0
};
}
start(link) {
this.sizeOverlay();
this.album = [];
let imageNumber = 0;
const setName = link.getAttribute('data-lightbox');
if (setName) {
const links = [...document.querySelectorAll(`${link.tagName}[data-lightbox="${CSS.escape(setName)}"]`)];
links.forEach((item, index) => {
this.addToAlbum(item);
if (item === link) imageNumber = index;
});
} else if (link.getAttribute('rel') === 'lightbox') {
this.addToAlbum(link);
} else {
const rel = link.getAttribute('rel');
const links = [...document.querySelectorAll(`${link.tagName}[rel="${CSS.escape(rel)}"]`)];
links.forEach((item, index) => {
this.addToAlbum(item);
if (item === link) imageNumber = index;
});
}
this.fade(this.overlay, true, this.options.fadeDuration);
this.fade(this.lightbox, true, this.options.fadeDuration);
if (this.options.disableScrolling) document.body.classList.add('lb-disable-scrolling');
window.addEventListener('resize', this.boundOnResize);
this.changeImage(imageNumber);
}
addToAlbum(link) {
const img = link.querySelector('img');
this.album.push({
alt: link.getAttribute('data-alt') || '',
link: link.getAttribute('href'),
title: link.getAttribute('data-title') || link.getAttribute('title') || '',
orientation: parseInt(link.getAttribute('data-orientation') || '0', 10),
type: link.getAttribute('data-type') || 'image',
id: link.getAttribute('data-id'),
width: parseInt(img?.getAttribute('width') || '0', 10),
height: parseInt(img?.getAttribute('height') || '0', 10),
set: link.getAttribute('data-lightbox') || link.getAttribute('rel') || ''
});
}
getMaxSizes(mediaWidth, mediaHeight, mediaType) {
let maxWidth = window.innerWidth - this.containerPadding.left - this.containerPadding.right;
let maxHeight = window.innerHeight - this.containerPadding.top - this.containerPadding.bottom - this.options.positionFromTop;
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
maxWidth -= border.left + border.right;
maxHeight -= border.top + border.bottom;
const dataMaxWidth = this.dataContainer.offsetWidth || 0;
const dataMaxHeight = this.dataContainer.offsetHeight || 0;
const ratio = mediaWidth / mediaHeight;
const heightH = Math.min(maxHeight, mediaHeight);
const widthH = Math.min(heightH * ratio, maxWidth - dataMaxWidth);
const surfaceH = Math.min(heightH, widthH / ratio) * widthH;
const widthV = Math.min(maxWidth, mediaWidth);
const heightV = Math.min(widthV / ratio, maxHeight - dataMaxHeight);
const surfaceV = Math.min(widthV, heightV * ratio) * heightV;
const direction = surfaceV > surfaceH ? 'vertical' : 'horizontal';
if (direction === 'vertical') maxHeight -= dataMaxHeight;
else maxWidth -= dataMaxWidth;
return { maxWidth, maxHeight, direction };
}
updateSize(index) {
const media = this.album[index];
const maxSizes = this.getMaxSizes(media.width, media.height, media.type);
let maxWidth = maxSizes.maxWidth;
let maxHeight = maxSizes.maxHeight;
this.lightbox.classList.remove('vertical', 'horizontal');
this.lightbox.classList.add(maxSizes.direction);
if (this.options.fitImagesInViewport) {
if (this.options.maxWidth && this.options.maxWidth < maxWidth) maxWidth = this.options.maxWidth;
if (this.options.maxHeight && this.options.maxHeight < maxHeight) maxHeight = this.options.maxHeight;
} else {
maxWidth = this.options.maxWidth || media.width || maxWidth;
maxHeight = this.options.maxHeight || media.height || maxHeight;
}
let finalWidth;
let finalHeight;
if (media.width > maxWidth || media.height > maxHeight) {
if ((media.width / maxWidth) > (media.height / maxHeight)) {
finalWidth = maxWidth;
finalHeight = Math.round(media.height / (media.width / maxWidth));
} else {
finalWidth = Math.round(media.width / (media.height / maxHeight));
finalHeight = maxHeight;
}
} else {
finalWidth = media.width;
finalHeight = media.height;
}
const target = media.type === 'video' ? this.video : this.image;
target.width = finalWidth;
target.height = finalHeight;
this.sizeContainer(finalWidth, finalHeight, media.type);
}
changeImage(index) {
const media = this.album[index];
if (!media) return;
this.disableKeyboardNav();
this.fade(this.overlay, true, this.options.fadeDuration);
this.fade(this.loader, true, 200);
this.hideElements([this.image, this.video, this.nav, this.prev, this.next, this.caption, this.closeButton]);
this.resetImageTransform();
this.dataContainer.style.width = '200px';
this.dataContainer.style.height = '30px';
this.outerContainer.classList.add('animating');
this.container.classList.remove('moveable', 'moving', 'lb-video-nav');
this.options.onMediaChange(media);
if (media.type === 'video') {
this.image.removeAttribute('src');
this.container.classList.add('lb-video-nav');
this.video.onloadedmetadata = () => {
media.width = this.video.videoWidth;
media.height = this.video.videoHeight;
this.video.onloadedmetadata = null;
this.updateSize(index);
};
this.video.src = media.link;
} else {
this.video.pause();
this.video.removeAttribute('src');
this.image.onload = () => {
this.image.alt = media.alt;
let width = this.image.naturalWidth;
let height = this.image.naturalHeight;
if (Math.abs(media.orientation) === 90 && width > height) {
const tmp = width;
width = height;
height = tmp;
}
media.width = width;
media.height = height;
this.image.onload = null;
this.updateSize(index);
};
this.image.src = media.link;
}
this.currentImageIndex = index;
}
sizeOverlay() {
if (this.resizeTimer) clearTimeout(this.resizeTimer);
if (!this.album.length) return;
this.resizeTimer = window.setTimeout(() => {
const current = this.album[this.currentImageIndex];
if (!current) return;
if (current.type === 'image') this.changeImage(this.currentImageIndex);
else this.updateSize(this.currentImageIndex);
}, 200);
}
sizeContainer(width, height, mediaType = 'image') {
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
const newWidth = width + this.containerPadding.left + this.containerPadding.right + border.left + border.right;
const newHeight = height + this.containerPadding.top + this.containerPadding.bottom + border.top + border.bottom;
this.outerContainer.style.transition = `width ${this.options.resizeDuration}ms, height ${this.options.resizeDuration}ms`;
this.outerContainer.style.width = `${newWidth}px`;
this.outerContainer.style.height = `${newHeight}px`;
window.setTimeout(() => {
if (this.lightbox.classList.contains('vertical')) this.dataContainer.style.width = `${newWidth}px`;
else this.dataContainer.style.height = `${newHeight}px`;
this.overlay.focus();
this.showImage();
this.outerContainer.style.transition = '';
}, this.options.resizeDuration);
}
showImage() {
this.fade(this.loader, false, 0);
if (this.options.hasVideo && this.album[this.currentImageIndex].type === 'video') this.fade(this.video, true, this.options.imageFadeDuration);
else this.fade(this.image, true, this.options.imageFadeDuration);
this.updateNav();
this.updateDetails();
this.preloadNeighboringImages();
this.enableKeyboardNav();
}
updateNav() {
this.nav.style.display = 'block';
this.prev.style.display = 'none';
this.next.style.display = 'none';
const alwaysShowNav = ('ontouchstart' in window) && this.options.alwaysShowNavOnTouchDevices;
if (this.album.length <= 1) return;
if (this.options.wrapAround) {
this.prev.style.display = '';
this.next.style.display = '';
} else {
if (this.currentImageIndex > 0) this.prev.style.display = '';
if (this.currentImageIndex < this.album.length - 1) this.next.style.display = '';
}
if (alwaysShowNav) {
this.prev.style.opacity = '1';
this.next.style.opacity = '1';
} else {
this.prev.style.opacity = '';
this.next.style.opacity = '';
}
}
updateDetails() {
const media = this.album[this.currentImageIndex];
if (!media) return;
if (media.title) {
if (this.options.sanitizeTitle) this.caption.textContent = media.title;
else this.caption.innerHTML = media.title;
this.fade(this.caption, true, 200);
this.fade(this.closeButton, true, 200);
} else {
this.caption.textContent = '';
this.caption.style.display = 'none';
}
this.outerContainer.classList.remove('animating');
this.fade(this.dataContainer, true, this.options.resizeDuration);
}
preloadNeighboringImages() {
const next = this.album[this.currentImageIndex + 1];
const prev = this.album[this.currentImageIndex - 1];
if (next && next.type === 'image') {
const preloadNext = new Image();
preloadNext.src = next.link;
}
if (prev && prev.type === 'image') {
const preloadPrev = new Image();
preloadPrev.src = prev.link;
}
}
enableKeyboardNav() {
this.disableKeyboardNav();
this.lightbox.addEventListener('keyup', this.boundOnKeyUp);
this.overlay.addEventListener('keyup', this.boundOnKeyUp);
}
disableKeyboardNav() {
this.lightbox?.removeEventListener('keyup', this.boundOnKeyUp);
this.overlay?.removeEventListener('keyup', this.boundOnKeyUp);
}
keyboardAction(event) {
switch (event.key) {
case 'Escape':
event.stopPropagation();
this.end();
break;
case 'ArrowLeft':
if (this.currentImageIndex !== 0) this.changeImage(this.currentImageIndex - 1);
else if (this.options.wrapAround && this.album.length > 1) this.changeImage(this.album.length - 1);
break;
case 'ArrowRight':
if (this.currentImageIndex !== this.album.length - 1) this.changeImage(this.currentImageIndex + 1);
else if (this.options.wrapAround && this.album.length > 1) this.changeImage(0);
break;
}
}
onWheel(event) {
const media = this.album[this.currentImageIndex];
if (!media || media.type === 'video') return;
event.preventDefault();
const rect = this.image.getBoundingClientRect();
const oldZoom = parseFloat(this.image.style.getPropertyValue('--scale') || '1');
const oldTranslateX = parseFloat(this.image.style.getPropertyValue('--translate-x') || '0');
const oldTranslateY = parseFloat(this.image.style.getPropertyValue('--translate-y') || '0');
const maxZoom = Math.max(media.width / Math.max(this.image.width, 1), media.height / Math.max(this.image.height, 1), 1);
const newZoom = Math.min(Math.max(oldZoom + (-Math.sign(event.deltaY) / 10), 1), maxZoom);
const offsetX = event.clientX - rect.left;
const offsetY = event.clientY - rect.top;
let translateX = oldTranslateX + (newZoom - oldZoom) * ((this.image.width / 2) - offsetX);
let translateY = oldTranslateY + (newZoom - oldZoom) * ((this.image.height / 2) - offsetY);
const maxTranslateX = (newZoom - 1) * this.image.width / 2;
const maxTranslateY = (newZoom - 1) * this.image.height / 2;
translateX = Math.max(Math.min(translateX, maxTranslateX), -maxTranslateX);
translateY = Math.max(Math.min(translateY, maxTranslateY), -maxTranslateY);
this.container.classList.toggle('moveable', newZoom > 1);
this.image.style.setProperty('--scale', String(newZoom));
this.image.style.setProperty('--translate-x', `${translateX}px`);
this.image.style.setProperty('--translate-y', `${translateY}px`);
}
onDragStart(event) {
const scale = parseFloat(this.image.style.getPropertyValue('--scale') || '1');
if (scale <= 1) return;
this.gMouseDownOffsetX = event.clientX - parseFloat(this.image.style.getPropertyValue('--translate-x') || '0');
this.gMouseDownOffsetY = event.clientY - parseFloat(this.image.style.getPropertyValue('--translate-y') || '0');
this.container.classList.add('moving');
window.addEventListener('mousemove', this.boundOnDragMove);
}
onDragMove(event) {
const zoom = parseFloat(this.image.style.getPropertyValue('--scale') || '1');
let translateX = event.clientX - this.gMouseDownOffsetX;
let translateY = event.clientY - this.gMouseDownOffsetY;
const maxTranslateX = (zoom - 1) * this.image.width / 2;
const maxTranslateY = (zoom - 1) * this.image.height / 2;
translateX = Math.max(Math.min(translateX, maxTranslateX), -maxTranslateX);
translateY = Math.max(Math.min(translateY, maxTranslateY), -maxTranslateY);
this.image.style.setProperty('--translate-x', `${translateX}px`);
this.image.style.setProperty('--translate-y', `${translateY}px`);
}
onDragEnd() {
window.removeEventListener('mousemove', this.boundOnDragMove);
this.container?.classList.remove('moving');
}
resetImageTransform() {
this.image.style.setProperty('--scale', '1');
this.image.style.setProperty('--translate-x', '0px');
this.image.style.setProperty('--translate-y', '0px');
}
hideElements(elements) {
elements.forEach((element) => {
if (element) element.style.display = 'none';
});
}
fade(element, show, duration, done) {
if (!element) return;
const safeDuration = duration || 0;
element.style.transition = `opacity ${safeDuration}ms`;
if (show) {
element.style.display = element === this.lightbox ? 'flex' : 'block';
requestAnimationFrame(() => {
element.style.opacity = element === this.overlay ? '0.8' : '1';
});
} else {
element.style.opacity = '0';
window.setTimeout(() => {
element.style.display = 'none';
}, safeDuration);
}
if (typeof done === 'function') {
window.setTimeout(done, safeDuration);
}
}
end() {
this.disableKeyboardNav();
this.video.pause();
this.video.removeAttribute('src');
this.container.classList.remove('lb-video-nav', 'moveable', 'moving');
window.removeEventListener('resize', this.boundOnResize);
window.removeEventListener('mousemove', this.boundOnDragMove);
this.fade(this.lightbox, false, this.options.fadeDuration);
this.fade(this.overlay, false, this.options.fadeDuration);
if (this.options.disableScrolling) document.body.classList.remove('lb-disable-scrolling');
this.options.onClosing();
}
}
const lightbox = new Lightbox();
export const options = defaults;
export default lightbox;