/** * 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 = `
Laden...
1 / 1
`; 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; })();