bericht/js/imageviewer.js
Eduard Wisch 4102ebfe5b
All checks were successful
Deploy bericht / deploy (push) Successful in 13s
Editor-Ueberarbeitung 1.5.0: Anhaenge-Kacheln, echte Seitengeometrie, Arbeiten ohne Reload [deploy]
Anhaenge-Spalte: Kachelraster mit Server-Thumbnails statt Dateinamen-Liste (Bilder per GD,
PDFs per PDF.js), Grossansicht per Klick, Auswahlreihenfolge sichtbar, Sortierung/Filter
(Aufnahmezeit per eigenem EXIF-Parser, da im Prod-Container ohne exif-Modul), ziehbare
Spaltenbreite, Kachelgroesse, Drag & Drop auf Seitenliste/Arbeitsflaeche/Raster-Plaetze.

Arbeitstisch zeigt jetzt Kopf-, Inhalts- und Fussbereich passend zur echten Seitengeometrie
(bericht_page_geometry()) - Editor und PDF-Erzeugung rechnen mit denselben Werten, das PDF
setzt die Arbeitsflaeche 1:1 statt sie ein zweites Mal einzupassen.

Neu: Bild einer Seite austauschen (auch einzelne Raster-Plaetze), Seiten mehrfach markieren
und sammeln loeschen/duplizieren, Autosave mit Verlassen-Warnung, Tastenkuerzel, kein
location.reload() mehr nach Hinzufuegen/Loeschen/Hochladen (ajax/fragments.php).

Nebenbei behoben: dol_dir_list() ohne mode=1 (Dateigroesse leer), PDF.js-Worker-Pfad ohne
/custom-Praefix, PDFs liefen als Download statt inline, Groessen-Select blieb leer,
Mehrfach-Upload nahm nur die erste Datei, Race-Condition bei schnellem Seitenwechsel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:33:20 +02:00

540 lines
19 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">
<iframe class="bericht-viewer-pdf" src="about:blank" title="PDF-Vorschau"></iframe>
</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.pdfFrame = this.overlay.querySelector('.bericht-viewer-pdf');
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) => {
if (this.isPdf()) return; // PDF-Viewer des Browsers zoomt selbst
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) => { if (!this.isPdf()) this.handleTouchStart(e); }, { passive: false });
this.container.addEventListener('touchmove', (e) => { if (!this.isPdf()) this.handleTouchMove(e); }, { passive: false });
this.container.addEventListener('touchend', (e) => { if (!this.isPdf()) 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 = '';
// Quellen leeren (Speicher freigeben, PDF-Rendering stoppen)
this.pdfFrame.src = 'about:blank';
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...';
if (current.type === 'pdf') {
// PDFs im eingebauten Browser-Viewer: der kann scrollen, zoomen, drucken.
this.img.style.display = 'none';
this.img.src = '';
this.pdfFrame.style.display = 'block';
this.pdfFrame.src = current.url;
this.zoomBtns.style.display = 'none';
this.loadingEl.style.display = 'none';
return;
}
this.pdfFrame.style.display = 'none';
this.pdfFrame.src = 'about:blank';
this.zoomBtns.style.display = '';
this.img.style.display = '';
this.img.style.opacity = '0';
this.img.src = current.url;
this.img.alt = current.title || 'Bild ' + (this.currentIndex + 1);
}
/** Ist der aktuell angezeigte Eintrag ein PDF? */
isPdf() {
const cur = this.images[this.currentIndex];
return !!(cur && cur.type === 'pdf');
}
/**
* 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;
})();