diff --git a/bericht_card.php b/bericht_card.php index caf6e01..1840baa 100644 --- a/bericht_card.php +++ b/bericht_card.php @@ -129,7 +129,7 @@ if ($action === 'delete' && $berichtid > 0 && $user->hasRight('bericht', 'delete * Anzeige */ $title = $langs->trans("Bericht").' — '.$parent->ref; -llxHeader('', $title, '', '', 0, 0, array(), array(), '', 'mod-bericht page-bericht-card'); +llxHeader('', $title, '', '', 0, 0, array(), array(dol_buildpath('/bericht/css/imageviewer.css', 1)), '', 'mod-bericht page-bericht-card'); // Header des Parent-Objekts (Standard-Dolibarr-Tabs für Rechnung/Auftrag/Angebot) if ($element === 'invoice') { @@ -427,11 +427,28 @@ document.addEventListener("click", function(e) { list($src, $ref) = explode(':', $key, 2); print '
'.dol_escape_htmltag($ref).'
'; 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; + +})();