Convert Map & Lightbox to vue components

This commit is contained in:
franzz committed 2026-08-27 20:37:02 +02:00
1 parent d29403d9c5
commit 4ae0e46a7d
6 files changed
+1192 -1187

No files matched your search

+585
View File
@@ -0,0 +1,585 @@
<script>
import AppIcon from '@components/AppIcon';
import { getStyleProperty } from '@scripts/common';
/* lightbox (https://github.com/lokesh/lightbox2) converted to a vue component and improved to support videos */
export default {
components: {
AppIcon
},
props: {
alwaysShowNavOnTouchDevices: {type: Boolean, default: false},
positionFromTop: {type: Number, default: 50},
wrapAround: {type: Boolean, default: false},
disableScrolling: {type: Boolean, default: false},
sanitizeTitle: {type: Boolean, default: false},
hasVideo: {type: Boolean, default: true},
maxWidth: {type: Number, default: null},
maxHeight: {type: Number, default: null}
},
emits: ['media-change', 'closing'],
data() {
/*
fadeDuration/imageFadeDuration/resizeDuration read --trans-quick/--trans-slow
directly rather than being passed down as props from Project.vue: they're
declared on :root (_common.scss), so they're available immediately.
No need to wait on Project.vue's own $el to mount first, and no risk of
a parent-to-child prop update lagging behind the first time this opens.
*/
const fadeDuration = parseFloat(getStyleProperty('--trans-quick'));
const resizeDuration = parseFloat(getStyleProperty('--trans-slow'));
return {
album: [],
currentImageIndex: 0,
gMouseDownOffsetX: 0,
gMouseDownOffsetY: 0,
resizeTimer: null,
containerPadding: null,
imageBorderWidth: null,
videoBorderWidth: null,
fadeDuration,
imageFadeDuration: fadeDuration,
resizeDuration
};
},
mounted() {
this.setVisible(this.$refs.overlay, false);
this.setVisible(this.$refs.lightboxEl, false);
this.containerPadding = this.getBoxMetrics(this.$refs.container, 'padding');
this.imageBorderWidth = this.getBoxMetrics(this.$refs.image, 'border');
this.videoBorderWidth = this.getBoxMetrics(this.$refs.video, 'border');
this.$refs.nav.addEventListener('wheel', this.onWheel, {passive: false});
this.$refs.nav.addEventListener('mousedown', this.onDragStart);
window.addEventListener('mouseup', this.onDragEnd);
this.enable();
},
beforeUnmount() {
this.disable();
if(this.resizeTimer) clearTimeout(this.resizeTimer);
window.removeEventListener('mouseup', this.onDragEnd);
window.removeEventListener('resize', this.sizeOverlay);
window.removeEventListener('mousemove', this.onDragMove);
},
methods: {
enable() {
document.body.addEventListener('click', this.onBodyClick);
},
disable() {
document.body.removeEventListener('click', this.onBodyClick);
},
onBodyClick(event) {
const link = event.target.closest('a[data-lightbox], area[data-lightbox]');
if(!link) return;
event.preventDefault();
this.start(link);
},
start(link) {
this.sizeOverlay();
this.album = [];
let imageNumber = 0;
const setName = link.getAttribute('data-lightbox');
const links = [...document.querySelectorAll(`${link.tagName}[data-lightbox="${CSS.escape(setName)}"]`)];
links.forEach((item, index) => {
this.addToAlbum(item);
if(item === link) imageNumber = index;
});
this.fade(this.$refs.overlay, true, this.fadeDuration);
this.fade(this.$refs.lightboxEl, true, this.fadeDuration);
if(this.disableScrolling) document.body.classList.add('lb-disable-scrolling');
window.addEventListener('resize', this.sizeOverlay);
this.changeImage(imageNumber);
},
end(dispose = false) {
this.disableKeyboardNav();
this.$refs.video?.pause();
this.$refs.video?.removeAttribute('src');
this.$refs.container?.classList.remove('lb-video-nav', 'moveable', 'moving');
window.removeEventListener('resize', this.sizeOverlay);
window.removeEventListener('mousemove', this.onDragMove);
if(dispose) {
this.album = [];
}
else {
this.fade(this.$refs.lightboxEl, false, this.fadeDuration);
this.fade(this.$refs.overlay, false, this.fadeDuration);
this.$emit('closing');
}
if(this.disableScrolling) document.body.classList.remove('lb-disable-scrolling');
},
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') || ''
});
},
hasMediaAfterCurrent() {
return this.currentImageIndex < this.album.length - 1;
},
refreshAlbum() {
const current = this.album[this.currentImageIndex];
if(!current?.set) return;
const links = [...document.querySelectorAll(`a[data-lightbox="${CSS.escape(current.set)}"], area[data-lightbox="${CSS.escape(current.set)}"]`)];
if(!links.length) return;
const existingKeys = new Set(this.album.map((media) => this.getMediaKey(media)));
links.forEach((link) => {
const key = this.getLinkMediaKey(link);
if(existingKeys.has(key)) return;
this.addToAlbum(link);
existingKeys.add(key);
});
this.updateNav();
},
getMediaKey(media) {
return `${media.set}:${media.id}`;
},
getLinkMediaKey(link) {
return `${link.getAttribute('data-lightbox') || ''}:${link.getAttribute('data-id')}`;
},
getMaxSizes(mediaType) {
let maxWidth = window.innerWidth - this.containerPadding.left - this.containerPadding.right;
let maxHeight = window.innerHeight - this.containerPadding.top - this.containerPadding.bottom - this.positionFromTop;
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
maxWidth -= border.left + border.right;
maxHeight -= border.top + border.bottom;
maxHeight -= this.getDataContainerHeight(maxWidth + this.containerPadding.left + this.containerPadding.right + border.left + border.right);
return {
maxWidth: Math.max(maxWidth, 1),
maxHeight: Math.max(maxHeight, 1)
};
},
getDataContainerHeight(width = null) {
if(!this.$refs.dataContainer) return 0;
const currentWidth = this.$refs.dataContainer.style.width;
if(width !== null) this.$refs.dataContainer.style.width = `${width}px`;
const height = Math.ceil(this.$refs.dataContainer.getBoundingClientRect().height || this.$refs.dataContainer.offsetHeight || 0);
this.$refs.dataContainer.style.width = currentWidth;
return height;
},
getMediaSize(media, maxWidth, maxHeight) {
if(media.width <= maxWidth && media.height <= maxHeight) {
return {
width: media.width,
height: media.height
};
}
const widthRatio = media.width / maxWidth;
const heightRatio = media.height / maxHeight;
if(widthRatio > heightRatio) {
return {
width: maxWidth,
height: Math.round(media.height / widthRatio)
};
}
return {
width: Math.round(media.width / heightRatio),
height: maxHeight
};
},
fitSizeWithDataContainer(size, mediaType) {
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
const maxOuterHeight = Math.max(window.innerHeight - this.positionFromTop, 1);
let fittedSize = size;
for(let i = 0; i < 5; i++) {
const containerWidth = fittedSize.width + this.containerPadding.left + this.containerPadding.right + border.left + border.right;
const containerHeight = fittedSize.height + this.containerPadding.top + this.containerPadding.bottom + border.top + border.bottom;
const dataHeight = this.getDataContainerHeight(containerWidth);
const overflow = Math.ceil(containerHeight + dataHeight - maxOuterHeight);
if(overflow <= 0 || fittedSize.height <= 1) break;
const height = Math.max(fittedSize.height - overflow, 1);
fittedSize = {
width: Math.max(Math.round(fittedSize.width * (height / fittedSize.height)), 1),
height
};
}
return fittedSize;
},
updateSize(index) {
const media = this.album[index];
const maxSizes = this.getMaxSizes(media.type);
const maxWidth = this.maxWidth ? Math.min(this.maxWidth, maxSizes.maxWidth) : maxSizes.maxWidth;
const maxHeight = this.maxHeight ? Math.min(this.maxHeight, maxSizes.maxHeight) : maxSizes.maxHeight;
const size = this.fitSizeWithDataContainer(this.getMediaSize(media, maxWidth, maxHeight), media.type);
const target = media.type === 'video' ? this.$refs.video : this.$refs.image;
target.width = size.width;
target.height = size.height;
this.sizeContainer(size.width, size.height, media.type);
},
changeImage(index) {
const media = this.album[index];
if(!media) return;
this.updateDetails(media, false);
this.hideElements([this.$refs.dataContainer]);
this.disableKeyboardNav();
this.fade(this.$refs.overlay, true, this.fadeDuration);
this.fade(this.$refs.loader, true, 200);
this.hideElements([this.$refs.image, this.$refs.video, this.$refs.nav, this.$refs.prev, this.$refs.next]);
this.resetImageTransform();
this.$refs.outerContainer.classList.add('animating');
this.$refs.container.classList.remove('moveable', 'moving', 'lb-video-nav');
this.currentImageIndex = index;
this.$emit('media-change', media);
if(media.type === 'video') {
this.$refs.image.removeAttribute('src');
this.$refs.container.classList.add('lb-video-nav');
this.$refs.video.onloadedmetadata = () => {
media.width = this.$refs.video.videoWidth;
media.height = this.$refs.video.videoHeight;
this.$refs.video.onloadedmetadata = null;
this.updateSize(index);
};
this.$refs.video.src = media.link;
} else {
this.$refs.video.pause();
this.$refs.video.removeAttribute('src');
this.$refs.image.onload = () => {
this.$refs.image.alt = media.alt;
let width = this.$refs.image.naturalWidth;
let height = this.$refs.image.naturalHeight;
if(Math.abs(media.orientation) === 90 && width > height) {
const tmp = width;
width = height;
height = tmp;
}
media.width = width;
media.height = height;
this.$refs.image.onload = null;
this.updateSize(index);
};
this.$refs.image.src = media.link;
}
},
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;
const dataHeight = this.getDataContainerHeight(newWidth);
this.$refs.outerContainer.style.transition = `width ${this.resizeDuration}ms, height ${this.resizeDuration}ms`;
this.$refs.outerContainer.style.width = `${newWidth}px`;
this.$refs.outerContainer.style.height = `${newHeight + dataHeight}px`;
this.$refs.container.style.height = `${newHeight}px`;
window.setTimeout(() => {
this.$refs.overlay.focus();
this.showImage();
this.$refs.outerContainer.style.transition = '';
}, this.resizeDuration);
},
showImage() {
this.fade(this.$refs.loader, false, 0);
if(this.hasVideo && this.album[this.currentImageIndex].type === 'video') this.fade(this.$refs.video, true, this.imageFadeDuration);
else this.fade(this.$refs.image, true, this.imageFadeDuration);
this.updateNav();
this.updateDetails();
this.preloadNeighboringImages();
this.enableKeyboardNav();
},
updateNav() {
this.setVisible(this.$refs.nav, true);
this.setVisible(this.$refs.prev, false);
this.setVisible(this.$refs.next, false);
const alwaysShowNav = ('ontouchstart' in window) && this.alwaysShowNavOnTouchDevices;
if(this.album.length <= 1) return;
if(this.wrapAround) {
this.setVisible(this.$refs.prev, true);
this.setVisible(this.$refs.next, true);
} else {
if(this.currentImageIndex > 0) this.setVisible(this.$refs.prev, true);
if(this.currentImageIndex < this.album.length - 1) this.setVisible(this.$refs.next, true);
}
if(alwaysShowNav) {
this.$refs.prev.style.opacity = '1';
this.$refs.next.style.opacity = '1';
} else {
this.$refs.prev.style.opacity = '';
this.$refs.next.style.opacity = '';
}
},
updateDetails(media = this.album[this.currentImageIndex], show = true) {
if(!media) return;
if(media.title) {
if(this.sanitizeTitle) this.$refs.caption.textContent = media.title;
else this.$refs.caption.innerHTML = media.title;
if(show) this.fade(this.$refs.caption, true, 200);
else this.setVisible(this.$refs.caption, true);
} else {
this.$refs.caption.textContent = '';
this.setVisible(this.$refs.caption, false);
}
if(show) {
this.fade(this.$refs.closeButton, true, 200);
this.$refs.outerContainer.classList.remove('animating');
this.fade(this.$refs.dataContainer, true, this.resizeDuration);
} else {
this.setVisible(this.$refs.closeButton, true);
this.setVisible(this.$refs.dataContainer, false);
this.$refs.dataContainer.style.transition = '';
this.$refs.dataContainer.style.opacity = '0';
}
},
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.$refs.lightboxEl.addEventListener('keyup', this.keyboardAction);
this.$refs.overlay.addEventListener('keyup', this.keyboardAction);
},
disableKeyboardNav() {
this.$refs.lightboxEl?.removeEventListener('keyup', this.keyboardAction);
this.$refs.overlay?.removeEventListener('keyup', this.keyboardAction);
},
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.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.wrapAround && this.album.length > 1) this.changeImage(0);
break;
}
},
onCloseKeyup(event) {
if(event.key === 'Enter' || event.key === ' ') this.end();
},
onPrevClick() {
if(this.currentImageIndex === 0) this.changeImage(this.album.length - 1);
else this.changeImage(this.currentImageIndex - 1);
},
onNextClick() {
if(this.currentImageIndex === this.album.length - 1) this.changeImage(0);
else this.changeImage(this.currentImageIndex + 1);
},
onOuterContainerClick(event) {
if(event.target === this.$refs.outerContainer) this.end();
},
onLightboxClick(event) {
if(event.target === this.$refs.lightboxEl) this.end();
},
onWheel(event) {
const media = this.album[this.currentImageIndex];
if(!media || media.type === 'video') return;
event.preventDefault();
const rect = this.$refs.image.getBoundingClientRect();
const oldTransform = this.getImageTransform();
const oldZoom = oldTransform.scale;
const maxZoom = Math.max(media.width / Math.max(this.$refs.image.width, 1), media.height / Math.max(this.$refs.image.height, 1), 1);
const newZoom = Math.min(Math.max(oldZoom + (-Math.sign(event.deltaY) / 10), 1), maxZoom);
const imageCenterX = rect.left + rect.width / 2 - oldTransform.translateX;
const imageCenterY = rect.top + rect.height / 2 - oldTransform.translateY;
const cursorX = event.clientX - imageCenterX;
const cursorY = event.clientY - imageCenterY;
const zoomRatio = newZoom / oldZoom;
const transform = this.clampImageTransform({
scale: newZoom,
translateX: cursorX - zoomRatio * (cursorX - oldTransform.translateX),
translateY: cursorY - zoomRatio * (cursorY - oldTransform.translateY)
});
this.$refs.container.classList.toggle('moveable', newZoom > 1);
this.setImageTransform(transform);
},
onDragStart(event) {
const scale = parseFloat(this.$refs.image.style.getPropertyValue('--scale') || '1');
if(scale <= 1) return;
this.gMouseDownOffsetX = event.clientX - parseFloat(this.$refs.image.style.getPropertyValue('--translate-x') || '0');
this.gMouseDownOffsetY = event.clientY - parseFloat(this.$refs.image.style.getPropertyValue('--translate-y') || '0');
this.$refs.container.classList.add('moving');
window.addEventListener('mousemove', this.onDragMove);
},
onDragMove(event) {
const zoom = parseFloat(this.$refs.image.style.getPropertyValue('--scale') || '1');
const transform = this.clampImageTransform({
scale: zoom,
translateX: event.clientX - this.gMouseDownOffsetX,
translateY: event.clientY - this.gMouseDownOffsetY
});
this.setImageTransform(transform);
},
onDragEnd() {
window.removeEventListener('mousemove', this.onDragMove);
this.$refs.container?.classList.remove('moving');
},
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
};
},
resetImageTransform() {
this.setImageTransform({scale: 1, translateX: 0, translateY: 0});
},
getImageTransform() {
return {
scale: parseFloat(this.$refs.image.style.getPropertyValue('--scale') || '1'),
translateX: parseFloat(this.$refs.image.style.getPropertyValue('--translate-x') || '0'),
translateY: parseFloat(this.$refs.image.style.getPropertyValue('--translate-y') || '0')
};
},
clampImageTransform(transform) {
const maxTranslateX = (transform.scale - 1) * this.$refs.image.width / 2;
const maxTranslateY = (transform.scale - 1) * this.$refs.image.height / 2;
return {
scale: transform.scale,
translateX: Math.max(Math.min(transform.translateX, maxTranslateX), -maxTranslateX),
translateY: Math.max(Math.min(transform.translateY, maxTranslateY), -maxTranslateY)
};
},
setImageTransform(transform) {
if(!this.$refs.image) return;
this.$refs.image.style.setProperty('--scale', String(transform.scale));
this.$refs.image.style.setProperty('--translate-x', `${transform.translateX}px`);
this.$refs.image.style.setProperty('--translate-y', `${transform.translateY}px`);
},
hideElements(elements) {
elements.forEach((element) => {
this.setVisible(element, false);
});
},
setVisible(element, visible) {
if(!element) return;
element.style.visibility = visible ? 'visible' : 'hidden';
element.style.pointerEvents = visible ? '' : 'none';
},
fade(element, show, duration, done) {
if(!element) return;
const safeDuration = duration || 0;
element.style.transition = `opacity ${safeDuration}ms`;
if(show) {
this.setVisible(element, true);
requestAnimationFrame(() => {
element.style.opacity = element === this.$refs.overlay ? '0.8' : '1';
});
} else {
element.style.opacity = '0';
element.style.pointerEvents = 'none';
window.setTimeout(() => {
this.setVisible(element, false);
}, safeDuration);
}
if(typeof done === 'function') {
window.setTimeout(done, safeDuration);
}
}
}
};
</script>
<template>
<Teleport to="body">
<div id="lightboxOverlay" ref="overlay" tabindex="-1" class="lightboxOverlay" @click="end()"></div>
<div id="lightbox" ref="lightboxEl" tabindex="-1" class="lightbox" @click="onLightboxClick">
<div class="lb-outerContainer" ref="outerContainer" @click.stop="onOuterContainerClick">
<div class="lb-container" ref="container">
<img class="lb-image" ref="image" src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" alt="" />
<video class="lb-video" ref="video" controls autoplay></video>
<div class="lb-nav" ref="nav">
<div class="lb-prev-area">
<a class="lb-prev" ref="prev" aria-label="Previous image" href="" role="button" @click.prevent="onPrevClick">
<AppIcon :icon="'prev'" />
</a>
</div>
<div class="lb-next-area">
<a class="lb-next" ref="next" aria-label="Next image" href="" role="button" @click.prevent="onNextClick"><AppIcon :icon="'next'" /></a>
</div>
</div>
<div class="lb-loader" ref="loader" @click.prevent="end()">
<a class="lb-cancel" ref="cancel" href="#">
<AppIcon :icon="'cancel'" />
</a>
</div>
</div>
<div class="lb-dataContainer desktop" ref="dataContainer" @click="end()">
<div class="lb-data">
<div class="lb-details">
<span class="lb-caption" ref="caption"></span>
</div>
<div class="lb-closeContainer">
<a class="lb-close" ref="closeButton" href="#" role="button" @click.prevent.stop="end()" @keyup="onCloseKeyup">
<AppIcon :icon="'close'" :classes="'fa-lg'" />
</a>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>