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 = `
`; 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;