';
foreach ($files as $f) {
- $icon = (strpos($f['mime'], 'image') === 0) ? '🖼' : ((strpos($f['mime'], 'pdf') !== false) ? '📄' : '📎');
+ $is_image = (strpos($f['mime'], 'image') === 0);
+ $icon = $is_image ? '🖼' : ((strpos($f['mime'], 'pdf') !== false) ? '📄' : '📎');
print '
';
print '
';
- print '
'.$icon.'';
- print '
'.dol_escape_htmltag($f['filename']).'';
+
+ if ($is_image) {
+ // Bild: Klickbar für Viewer
+ // relpath ist z.B. "facture/FA2025-0001/image.jpg" - erstes Segment ist modulepart
+ $relpath_parts = explode('/', $f['relpath'], 2);
+ $modulepart = $relpath_parts[0];
+ $file_in_module = $relpath_parts[1] ?? '';
+ $img_url = DOL_URL_ROOT.'/viewimage.php?modulepart='.urlencode($modulepart).'&file='.urlencode($file_in_module).'&entity='.$conf->entity;
+ print '
';
+ print ''.$icon.'';
+ print ''.dol_escape_htmltag($f['filename']).'';
+ print '';
+ } else {
+ // Andere Dateien: nicht klickbar
+ print '
'.$icon.'';
+ print '
'.dol_escape_htmltag($f['filename']).'';
+ }
+
print '
'.dol_print_size($f['size']).'';
if ($user->hasRight('bericht', 'write')) {
print '
';
@@ -637,6 +654,7 @@ document.addEventListener("click", function(e) {
print '';
print '';
print '';
+ print '';
print '';
print '';
}
diff --git a/css/imageviewer.css b/css/imageviewer.css
new file mode 100644
index 0000000..402da8a
--- /dev/null
+++ b/css/imageviewer.css
@@ -0,0 +1,315 @@
+/**
+ * Bericht Image Viewer - Lightbox mit Touch-Zoom
+ * Standalone CSS für den Bild-Viewer im Bericht-Modul
+ */
+
+/* ========== Overlay (Fullscreen) ========== */
+.bericht-viewer-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.92);
+ z-index: 99999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.2s ease, visibility 0.2s ease;
+ -webkit-user-select: none;
+ user-select: none;
+ touch-action: none; /* Verhindert Browser-Gesten */
+}
+
+.bericht-viewer-overlay.active {
+ opacity: 1;
+ visibility: visible;
+}
+
+/* ========== Bild-Container ========== */
+.bericht-viewer-container {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+
+/* ========== Das Bild selbst ========== */
+.bericht-viewer-image {
+ max-width: 90vw;
+ max-height: 90vh;
+ object-fit: contain;
+ transform-origin: center center;
+ transition: transform 0.15s ease-out;
+ cursor: grab;
+ pointer-events: auto;
+}
+
+.bericht-viewer-image.dragging {
+ cursor: grabbing;
+ transition: none;
+}
+
+.bericht-viewer-image.zoomed {
+ max-width: none;
+ max-height: none;
+}
+
+/* ========== Schliessen-Button ========== */
+.bericht-viewer-close {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 44px;
+ height: 44px;
+ border: none;
+ background: rgba(255, 255, 255, 0.15);
+ color: #fff;
+ font-size: 28px;
+ line-height: 1;
+ cursor: pointer;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10;
+ transition: background 0.15s ease;
+}
+
+.bericht-viewer-close:hover,
+.bericht-viewer-close:focus {
+ background: rgba(255, 255, 255, 0.3);
+ outline: none;
+}
+
+/* ========== Navigation (Pfeile) ========== */
+.bericht-viewer-nav {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 56px;
+ height: 80px;
+ border: none;
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff;
+ font-size: 32px;
+ cursor: pointer;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.15s ease;
+}
+
+.bericht-viewer-nav:hover,
+.bericht-viewer-nav:focus {
+ background: rgba(255, 255, 255, 0.25);
+ outline: none;
+}
+
+.bericht-viewer-nav:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
+.bericht-viewer-nav.prev {
+ left: 8px;
+ border-radius: 4px 8px 8px 4px;
+}
+
+.bericht-viewer-nav.next {
+ right: 8px;
+ border-radius: 8px 4px 4px 8px;
+}
+
+/* ========== Bild-Counter (3 / 12) ========== */
+.bericht-viewer-counter {
+ position: absolute;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ color: #fff;
+ font-size: 14px;
+ background: rgba(0, 0, 0, 0.5);
+ padding: 6px 14px;
+ border-radius: 20px;
+ z-index: 10;
+ font-variant-numeric: tabular-nums;
+}
+
+/* ========== Zoom-Buttons ========== */
+.bericht-viewer-zoom {
+ position: absolute;
+ bottom: 20px;
+ right: 16px;
+ display: flex;
+ gap: 8px;
+ z-index: 10;
+}
+
+.bericht-viewer-zoom button {
+ width: 40px;
+ height: 40px;
+ border: none;
+ background: rgba(255, 255, 255, 0.15);
+ color: #fff;
+ font-size: 20px;
+ cursor: pointer;
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.15s ease;
+}
+
+.bericht-viewer-zoom button:hover,
+.bericht-viewer-zoom button:focus {
+ background: rgba(255, 255, 255, 0.3);
+ outline: none;
+}
+
+/* ========== Titel-Anzeige ========== */
+.bericht-viewer-title {
+ position: absolute;
+ bottom: 60px;
+ left: 50%;
+ transform: translateX(-50%);
+ color: #fff;
+ font-size: 13px;
+ background: rgba(0, 0, 0, 0.5);
+ padding: 6px 12px;
+ border-radius: 4px;
+ max-width: 80vw;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ z-index: 10;
+}
+
+/* ========== Lade-Indikator ========== */
+.bericht-viewer-loading {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: #fff;
+ font-size: 14px;
+ z-index: 5;
+}
+
+.bericht-viewer-loading::after {
+ content: '';
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: #fff;
+ border-radius: 50%;
+ margin-left: 8px;
+ animation: viewer-spin 0.8s linear infinite;
+ vertical-align: middle;
+}
+
+@keyframes viewer-spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ========== Anhang-Vorschau klickbar machen ========== */
+.att-preview {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ flex: 1;
+ min-width: 0;
+ padding: 2px 4px;
+ border-radius: 3px;
+ transition: background 0.15s ease;
+}
+
+.att-preview:hover {
+ background: rgba(0, 0, 0, 0.08);
+}
+
+.att-preview .att-icon {
+ flex-shrink: 0;
+}
+
+.att-preview .att-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* ========== Mobile Anpassungen ========== */
+@media (max-width: 768px) {
+ .bericht-viewer-nav {
+ width: 44px;
+ height: 60px;
+ font-size: 24px;
+ }
+
+ .bericht-viewer-nav.prev { left: 4px; }
+ .bericht-viewer-nav.next { right: 4px; }
+
+ .bericht-viewer-close {
+ top: 8px;
+ right: 8px;
+ width: 40px;
+ height: 40px;
+ font-size: 24px;
+ }
+
+ .bericht-viewer-zoom {
+ bottom: 16px;
+ right: 8px;
+ }
+
+ .bericht-viewer-zoom button {
+ width: 36px;
+ height: 36px;
+ font-size: 18px;
+ }
+
+ .bericht-viewer-counter {
+ bottom: 16px;
+ font-size: 12px;
+ padding: 4px 10px;
+ }
+
+ .bericht-viewer-title {
+ bottom: 50px;
+ font-size: 11px;
+ padding: 4px 8px;
+ }
+
+ .bericht-viewer-image {
+ max-width: 100vw;
+ max-height: 100vh;
+ }
+}
+
+/* ========== Swipe-Hinweis (einmalig bei erstem Öffnen) ========== */
+.bericht-viewer-hint {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 14px;
+ text-align: center;
+ pointer-events: none;
+ animation: viewer-hint-fade 2s ease-out forwards;
+ z-index: 15;
+}
+
+@keyframes viewer-hint-fade {
+ 0% { opacity: 1; }
+ 70% { opacity: 1; }
+ 100% { opacity: 0; visibility: hidden; }
+}
diff --git a/js/editor.js b/js/editor.js
index aa06e84..3ed80b2 100644
--- a/js/editor.js
+++ b/js/editor.js
@@ -84,6 +84,49 @@
} catch (e) { /* localStorage full/blocked */ }
}
+ /* ---------- Bild-Viewer für Anhänge ---------- */
+ function bindImageViewer() {
+ // Prüfen ob BerichtImageViewer geladen ist
+ if (typeof window.BerichtImageViewer === 'undefined') {
+ console.warn('BerichtImageViewer nicht geladen');
+ return;
+ }
+
+ const viewer = new window.BerichtImageViewer();
+ const previews = document.querySelectorAll('.att-preview');
+
+ if (previews.length === 0) return;
+
+ // Bilder pro Gruppe sammeln (für Navigation innerhalb der Gruppe)
+ const imagesByGroup = {};
+ previews.forEach((el) => {
+ const group = el.dataset.group || 'default';
+ if (!imagesByGroup[group]) imagesByGroup[group] = [];
+ imagesByGroup[group].push({
+ url: el.dataset.url,
+ title: el.dataset.filename || ''
+ });
+ });
+
+ // Klick-Handler
+ previews.forEach((el) => {
+ el.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const group = el.dataset.group || 'default';
+ const images = imagesByGroup[group] || [];
+ const url = el.dataset.url;
+
+ // Index in der Gruppe finden
+ let startIndex = images.findIndex(img => img.url === url);
+ if (startIndex < 0) startIndex = 0;
+
+ viewer.open(images, startIndex);
+ });
+ });
+ }
+
/* ---------- Init ---------- */
function init() {
// Leer-Zustand nur wenn GAR keine Seite existiert
@@ -138,6 +181,7 @@
bindExtraUpload();
bindActions();
bindSortable();
+ bindImageViewer();
// Re-Render bei Größenänderung des Container (Console öffnen, Window-Resize),
// debounced damit es nicht spamt. Nutzt ResizeObserver auf canvas-wrap.
diff --git a/js/imageviewer.js b/js/imageviewer.js
new file mode 100644
index 0000000..5880375
--- /dev/null
+++ b/js/imageviewer.js
@@ -0,0 +1,514 @@
+/**
+ * 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;
+
+})();