bericht/js/imageviewer.js
Eduard Wisch 7c21446f98
All checks were successful
Deploy bericht / deploy (push) Successful in 2s
Bild-Viewer mit Zoom und Swipe-Navigation [deploy]
- Neuer Lightbox-Viewer für Bilder in der Anhänge-Liste
- Pinch-to-Zoom (Touch) und Mausrad-Zoom (Desktop)
- Doppelklick/Doppeltap für Zoom-Toggle (1x ↔ 2.5x)
- Swipe links/rechts für Navigation zwischen Bildern
- Tastatur: ← → Esc + - 0
- Bilder pro Gruppe navigierbar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-13 13:00:17 +02:00

514 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Bericht Image Viewer - Lightbox mit Touch-Zoom und Swipe
*
* Features:
* - Pinch-to-Zoom (Touch)
* - Mausrad-Zoom
* - Doppelklick/Doppeltap für Zoom-Toggle
* - Swipe links/rechts für Navigation (bei zoom=1)
* - Pan/Drag bei gezoomtem Bild
* - Tastatur: Pfeiltasten, Escape
*/
(function () {
'use strict';
class BerichtImageViewer {
constructor() {
this.images = []; // Array von { url, title }
this.currentIndex = 0;
this.zoom = 1;
this.minZoom = 0.5;
this.maxZoom = 5;
this.panX = 0;
this.panY = 0;
// Touch-State
this.touchState = {
startX: 0,
startY: 0,
startDistance: 0,
startZoom: 1,
startPanX: 0,
startPanY: 0,
isDragging: false,
isPinching: false,
lastTapTime: 0
};
// DOM-Elemente (werden in buildUI erstellt)
this.overlay = null;
this.container = null;
this.img = null;
this.closeBtn = null;
this.prevBtn = null;
this.nextBtn = null;
this.counter = null;
this.zoomBtns = null;
this.titleEl = null;
this.loadingEl = null;
this.isOpen = false;
this.boundKeyHandler = this.handleKeyboard.bind(this);
this.buildUI();
this.bindEvents();
}
/**
* DOM-Struktur erstellen
*/
buildUI() {
// Overlay
this.overlay = document.createElement('div');
this.overlay.className = 'bericht-viewer-overlay';
this.overlay.innerHTML = `
<div class="bericht-viewer-container">
<div class="bericht-viewer-loading">Laden...</div>
<img class="bericht-viewer-image" src="" alt="" draggable="false">
</div>
<button type="button" class="bericht-viewer-close" title="Schliessen (Esc)">&times;</button>
<button type="button" class="bericht-viewer-nav prev" title="Vorheriges Bild (←)">&#8249;</button>
<button type="button" class="bericht-viewer-nav next" title="Nächstes Bild (→)">&#8250;</button>
<div class="bericht-viewer-counter">1 / 1</div>
<div class="bericht-viewer-title"></div>
<div class="bericht-viewer-zoom">
<button type="button" data-action="zoom-out" title="Verkleinern"></button>
<button type="button" data-action="zoom-reset" title="Zurücksetzen">⟳</button>
<button type="button" data-action="zoom-in" title="Vergrössern">+</button>
</div>
`;
this.container = this.overlay.querySelector('.bericht-viewer-container');
this.img = this.overlay.querySelector('.bericht-viewer-image');
this.closeBtn = this.overlay.querySelector('.bericht-viewer-close');
this.prevBtn = this.overlay.querySelector('.bericht-viewer-nav.prev');
this.nextBtn = this.overlay.querySelector('.bericht-viewer-nav.next');
this.counter = this.overlay.querySelector('.bericht-viewer-counter');
this.titleEl = this.overlay.querySelector('.bericht-viewer-title');
this.loadingEl = this.overlay.querySelector('.bericht-viewer-loading');
this.zoomBtns = this.overlay.querySelector('.bericht-viewer-zoom');
document.body.appendChild(this.overlay);
}
/**
* Event-Listener binden
*/
bindEvents() {
// Schliessen
this.closeBtn.addEventListener('click', () => this.close());
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay || e.target === this.container) {
this.close();
}
});
// Navigation
this.prevBtn.addEventListener('click', (e) => { e.stopPropagation(); this.prev(); });
this.nextBtn.addEventListener('click', (e) => { e.stopPropagation(); this.next(); });
// Zoom-Buttons
this.zoomBtns.addEventListener('click', (e) => {
e.stopPropagation();
const action = e.target.dataset.action;
if (action === 'zoom-in') this.zoomIn();
else if (action === 'zoom-out') this.zoomOut();
else if (action === 'zoom-reset') this.resetZoom();
});
// Mausrad-Zoom
this.container.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.2 : 0.2;
const rect = this.img.getBoundingClientRect();
const centerX = e.clientX - rect.left;
const centerY = e.clientY - rect.top;
this.setZoom(this.zoom + delta, centerX, centerY);
}, { passive: false });
// Touch-Events
this.container.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false });
this.container.addEventListener('touchmove', (e) => this.handleTouchMove(e), { passive: false });
this.container.addEventListener('touchend', (e) => this.handleTouchEnd(e));
// Maus-Drag (für Desktop)
this.img.addEventListener('mousedown', (e) => this.handleMouseDown(e));
document.addEventListener('mousemove', (e) => this.handleMouseMove(e));
document.addEventListener('mouseup', () => this.handleMouseUp());
// Doppelklick für Zoom-Toggle
this.img.addEventListener('dblclick', (e) => {
e.preventDefault();
const rect = this.img.getBoundingClientRect();
this.handleDoubleTap(e.clientX - rect.left, e.clientY - rect.top);
});
// Bild geladen
this.img.addEventListener('load', () => {
this.loadingEl.style.display = 'none';
this.img.style.opacity = '1';
});
this.img.addEventListener('error', () => {
this.loadingEl.textContent = 'Fehler beim Laden';
});
}
/**
* Viewer mit Bild-Array öffnen
* @param {Array<{url: string, title: string}>} images
* @param {number} startIndex
*/
open(images, startIndex = 0) {
if (!images || images.length === 0) return;
this.images = images;
this.currentIndex = Math.max(0, Math.min(startIndex, images.length - 1));
this.resetZoom();
this.overlay.classList.add('active');
this.isOpen = true;
// Tastatur-Listener
document.addEventListener('keydown', this.boundKeyHandler);
// Body-Scroll verhindern
document.body.style.overflow = 'hidden';
this.loadCurrentImage();
this.updateUI();
}
/**
* Viewer schliessen
*/
close() {
this.overlay.classList.remove('active');
this.isOpen = false;
document.removeEventListener('keydown', this.boundKeyHandler);
document.body.style.overflow = '';
// Bild-Source leeren (Speicher freigeben)
setTimeout(() => {
if (!this.isOpen) this.img.src = '';
}, 300);
}
/**
* Nächstes Bild
*/
next() {
if (this.currentIndex < this.images.length - 1) {
this.currentIndex++;
this.resetZoom();
this.loadCurrentImage();
this.updateUI();
}
}
/**
* Vorheriges Bild
*/
prev() {
if (this.currentIndex > 0) {
this.currentIndex--;
this.resetZoom();
this.loadCurrentImage();
this.updateUI();
}
}
/**
* Zu bestimmtem Index springen
*/
goTo(index) {
if (index >= 0 && index < this.images.length && index !== this.currentIndex) {
this.currentIndex = index;
this.resetZoom();
this.loadCurrentImage();
this.updateUI();
}
}
/**
* Aktuelles Bild laden
*/
loadCurrentImage() {
const current = this.images[this.currentIndex];
if (!current) return;
this.loadingEl.style.display = 'block';
this.loadingEl.textContent = 'Laden...';
this.img.style.opacity = '0';
this.img.src = current.url;
this.img.alt = current.title || 'Bild ' + (this.currentIndex + 1);
}
/**
* UI aktualisieren (Counter, Titel, Nav-Buttons)
*/
updateUI() {
// Counter
this.counter.textContent = (this.currentIndex + 1) + ' / ' + this.images.length;
// Titel
const current = this.images[this.currentIndex];
if (current && current.title) {
this.titleEl.textContent = current.title;
this.titleEl.style.display = 'block';
} else {
this.titleEl.style.display = 'none';
}
// Navigation-Buttons
this.prevBtn.disabled = this.currentIndex === 0;
this.nextBtn.disabled = this.currentIndex === this.images.length - 1;
// Bei nur einem Bild: Nav ausblenden
const showNav = this.images.length > 1;
this.prevBtn.style.display = showNav ? '' : 'none';
this.nextBtn.style.display = showNav ? '' : 'none';
}
/**
* Zoom setzen
* @param {number} level - Zoom-Level (0.5 - 5)
* @param {number} centerX - Zoom-Mittelpunkt X (relativ zum Bild)
* @param {number} centerY - Zoom-Mittelpunkt Y (relativ zum Bild)
*/
setZoom(level, centerX = null, centerY = null) {
const oldZoom = this.zoom;
this.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, level));
// Pan anpassen wenn um bestimmten Punkt gezoomt wird
if (centerX !== null && centerY !== null && oldZoom !== this.zoom) {
const ratio = this.zoom / oldZoom;
this.panX = centerX - (centerX - this.panX) * ratio;
this.panY = centerY - (centerY - this.panY) * ratio;
}
this.constrainPan();
this.applyTransform();
}
zoomIn() {
this.setZoom(this.zoom + 0.5);
}
zoomOut() {
this.setZoom(this.zoom - 0.5);
}
resetZoom() {
this.zoom = 1;
this.panX = 0;
this.panY = 0;
this.applyTransform();
}
/**
* Pan begrenzen (Bild nicht aus dem Sichtbereich schieben)
*/
constrainPan() {
if (this.zoom <= 1) {
this.panX = 0;
this.panY = 0;
return;
}
const rect = this.container.getBoundingClientRect();
const imgW = this.img.naturalWidth * this.zoom;
const imgH = this.img.naturalHeight * this.zoom;
// Max-Grenzen berechnen
const maxPanX = Math.max(0, (imgW - rect.width) / 2);
const maxPanY = Math.max(0, (imgH - rect.height) / 2);
this.panX = Math.max(-maxPanX, Math.min(maxPanX, this.panX));
this.panY = Math.max(-maxPanY, Math.min(maxPanY, this.panY));
}
/**
* Transform auf Bild anwenden
*/
applyTransform() {
this.img.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
// Klasse für zoomed-State (CSS verwendet das für cursor)
if (this.zoom > 1) {
this.img.classList.add('zoomed');
} else {
this.img.classList.remove('zoomed');
}
}
/**
* Tastatur-Handler
*/
handleKeyboard(e) {
if (!this.isOpen) return;
switch (e.key) {
case 'Escape':
this.close();
break;
case 'ArrowLeft':
this.prev();
break;
case 'ArrowRight':
this.next();
break;
case '+':
case '=':
this.zoomIn();
break;
case '-':
this.zoomOut();
break;
case '0':
this.resetZoom();
break;
}
}
/* ========== Touch-Gesten ========== */
handleTouchStart(e) {
if (e.touches.length === 1) {
// Single-Touch: Pan oder Swipe
const touch = e.touches[0];
this.touchState.startX = touch.clientX;
this.touchState.startY = touch.clientY;
this.touchState.startPanX = this.panX;
this.touchState.startPanY = this.panY;
this.touchState.isDragging = true;
this.touchState.isPinching = false;
// Doppeltap erkennen
const now = Date.now();
if (now - this.touchState.lastTapTime < 300) {
const rect = this.img.getBoundingClientRect();
this.handleDoubleTap(touch.clientX - rect.left, touch.clientY - rect.top);
this.touchState.lastTapTime = 0;
} else {
this.touchState.lastTapTime = now;
}
} else if (e.touches.length === 2) {
// Pinch-Zoom
e.preventDefault();
this.touchState.isPinching = true;
this.touchState.isDragging = false;
this.touchState.startDistance = this.getTouchDistance(e.touches);
this.touchState.startZoom = this.zoom;
// Mittelpunkt speichern
const rect = this.img.getBoundingClientRect();
this.touchState.pinchCenterX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
this.touchState.pinchCenterY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
}
}
handleTouchMove(e) {
if (this.touchState.isPinching && e.touches.length === 2) {
// Pinch-Zoom
e.preventDefault();
const distance = this.getTouchDistance(e.touches);
const scale = distance / this.touchState.startDistance;
const newZoom = this.touchState.startZoom * scale;
this.setZoom(newZoom, this.touchState.pinchCenterX, this.touchState.pinchCenterY);
} else if (this.touchState.isDragging && e.touches.length === 1) {
const touch = e.touches[0];
const deltaX = touch.clientX - this.touchState.startX;
const deltaY = touch.clientY - this.touchState.startY;
if (this.zoom > 1) {
// Gezoomt: Pan
e.preventDefault();
this.panX = this.touchState.startPanX + deltaX;
this.panY = this.touchState.startPanY + deltaY;
this.constrainPan();
this.applyTransform();
}
// Bei zoom=1 wird Swipe in touchend ausgewertet
}
}
handleTouchEnd(e) {
if (this.touchState.isDragging && this.zoom <= 1 && e.changedTouches.length > 0) {
// Swipe auswerten
const touch = e.changedTouches[0];
const deltaX = touch.clientX - this.touchState.startX;
const deltaY = touch.clientY - this.touchState.startY;
// Horizontaler Swipe (min 50px, mehr horizontal als vertikal)
if (Math.abs(deltaX) > 50 && Math.abs(deltaX) > Math.abs(deltaY) * 1.5) {
if (deltaX > 0) {
this.prev();
} else {
this.next();
}
}
}
this.touchState.isDragging = false;
this.touchState.isPinching = false;
}
/**
* Distanz zwischen zwei Touch-Punkten
*/
getTouchDistance(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.hypot(dx, dy);
}
/**
* Doppeltap/Doppelklick: Zoom-Toggle
*/
handleDoubleTap(x, y) {
if (this.zoom > 1) {
this.resetZoom();
} else {
this.setZoom(2.5, x, y);
}
}
/* ========== Maus-Drag (Desktop) ========== */
handleMouseDown(e) {
if (this.zoom > 1) {
e.preventDefault();
this.touchState.isDragging = true;
this.touchState.startX = e.clientX;
this.touchState.startY = e.clientY;
this.touchState.startPanX = this.panX;
this.touchState.startPanY = this.panY;
this.img.classList.add('dragging');
}
}
handleMouseMove(e) {
if (this.touchState.isDragging && this.zoom > 1) {
const deltaX = e.clientX - this.touchState.startX;
const deltaY = e.clientY - this.touchState.startY;
this.panX = this.touchState.startPanX + deltaX;
this.panY = this.touchState.startPanY + deltaY;
this.constrainPan();
this.applyTransform();
}
}
handleMouseUp() {
this.touchState.isDragging = false;
this.img.classList.remove('dragging');
}
}
// Global verfügbar machen
window.BerichtImageViewer = BerichtImageViewer;
})();