[deploy] Bild-Viewer mit Pinch-Zoom, Swipe und Navigation
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 0s

- Pinch-to-Zoom (2 Finger)
- Doppeltap für Zoom-Toggle
- Swipe links/rechts zum Bildwechsel
- Navigation-Buttons + Counter
- Tastatur: Pfeiltasten + Escape

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-13 13:26:56 +02:00
parent 645624eeb6
commit 0a9c4d4db3
2 changed files with 230 additions and 10 deletions

76
app.css
View file

@ -715,3 +715,79 @@ body {
/* help-btn in Topbar kleiner machen */
#help-btn { font-size: 18px; padding: 4px 8px; }
/* ========== Photo Viewer mit Zoom + Swipe ========== */
.photo-viewer-modal .pv-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: none;
background: #000;
}
.photo-viewer-modal #pv-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transform-origin: center center;
transition: transform 0.15s ease-out, opacity 0.2s;
cursor: grab;
user-select: none;
-webkit-user-drag: none;
}
.photo-viewer-modal #pv-image.dragging {
cursor: grabbing;
transition: none;
}
.photo-viewer-modal .pv-counter {
font-size: 13px;
color: #888;
margin-left: auto;
margin-right: 8px;
}
.photo-viewer-modal .pv-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 50px;
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;
border-radius: 4px;
}
.photo-viewer-modal .pv-nav:disabled { opacity: 0.2; cursor: default; }
.photo-viewer-modal .pv-prev { left: 8px; }
.photo-viewer-modal .pv-next { right: 8px; }
.photo-viewer-modal .pv-zoom-btns {
position: absolute;
bottom: 16px;
right: 12px;
display: flex;
gap: 8px;
z-index: 10;
}
.photo-viewer-modal .pv-zoom-btns button {
width: 40px;
height: 40px;
border: none;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 20px;
cursor: pointer;
border-radius: 6px;
}
.photo-viewer-modal .pv-zoom-btns button:active {
background: rgba(255, 255, 255, 0.3);
}
@media (max-width: 600px) {
.photo-viewer-modal .pv-nav { width: 40px; height: 60px; font-size: 24px; }
.photo-viewer-modal .pv-zoom-btns button { width: 36px; height: 36px; font-size: 18px; }
}

164
app.js
View file

@ -1514,41 +1514,185 @@ function escapeHtml(s) {
}
/* ============================================================
* PHOTO VOLLBILD MODAL + SKIZZEN-EDITOR
* PHOTO VOLLBILD MODAL MIT ZOOM + SWIPE + SKIZZEN-EDITOR
* ============================================================ */
async function openPhotoModal(orderId, relpath) {
const url = await api.getPhotoBlobUrl(relpath);
if (!url) { showToast('Foto konnte nicht geladen werden', 'error'); return; }
// Alle Bilder im Grid sammeln für Navigation
const allThumbs = Array.from(document.querySelectorAll('#photo-grid .thumb[data-relpath]'));
const allRelpaths = allThumbs.map(t => t.dataset.relpath);
let currentIndex = allRelpaths.indexOf(relpath);
if (currentIndex < 0) currentIndex = 0;
// Zoom/Pan State
let zoom = 1, panX = 0, panY = 0;
let currentUrl = null;
const touchState = { startX: 0, startY: 0, startDist: 0, startZoom: 1, startPanX: 0, startPanY: 0, isDragging: false, isPinching: false, lastTap: 0 };
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.className = 'fullscreen-modal photo-viewer-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="fs-close"></button>
<div class="fs-title">${escapeHtml(relpath.split('/').pop())}</div>
<div class="fs-title" id="pv-title"></div>
<div class="pv-counter" id="pv-counter"></div>
<button class="icon-btn" id="fs-sketch" title="Zeichnen"></button>
<button class="icon-btn" id="fs-delete" title="Löschen">🗑</button>
</div>
<div class="fs-body"><img src="${url}" alt=""></div>
<div class="pv-body" id="pv-body">
<img id="pv-image" src="" draggable="false">
</div>
<button class="pv-nav pv-prev" id="pv-prev"></button>
<button class="pv-nav pv-next" id="pv-next"></button>
<div class="pv-zoom-btns">
<button id="pv-zout"></button>
<button id="pv-zreset"></button>
<button id="pv-zin">+</button>
</div>
`;
document.body.appendChild(modal);
const close = () => modal.remove();
const img = modal.querySelector('#pv-image');
const body = modal.querySelector('#pv-body');
const titleEl = modal.querySelector('#pv-title');
const counter = modal.querySelector('#pv-counter');
const prevBtn = modal.querySelector('#pv-prev');
const nextBtn = modal.querySelector('#pv-next');
function resetZoom() { zoom = 1; panX = 0; panY = 0; applyTransform(); }
function applyTransform() { img.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`; }
function setZoom(z) {
zoom = Math.max(0.5, Math.min(5, z));
constrainPan();
applyTransform();
}
function constrainPan() {
if (zoom <= 1) { panX = 0; panY = 0; return; }
const rect = body.getBoundingClientRect();
const maxX = Math.max(0, (img.naturalWidth * zoom - rect.width) / 2);
const maxY = Math.max(0, (img.naturalHeight * zoom - rect.height) / 2);
panX = Math.max(-maxX, Math.min(maxX, panX));
panY = Math.max(-maxY, Math.min(maxY, panY));
}
async function loadImage(idx) {
if (idx < 0 || idx >= allRelpaths.length) return;
currentIndex = idx;
const rp = allRelpaths[idx];
titleEl.textContent = rp.split('/').pop();
counter.textContent = `${idx + 1} / ${allRelpaths.length}`;
prevBtn.disabled = idx === 0;
nextBtn.disabled = idx === allRelpaths.length - 1;
prevBtn.style.display = nextBtn.style.display = allRelpaths.length > 1 ? '' : 'none';
img.style.opacity = '0.3';
const url = await api.getPhotoBlobUrl(rp);
if (url) {
currentUrl = url;
img.src = url;
img.onload = () => { img.style.opacity = '1'; };
}
resetZoom();
}
function close() { modal.remove(); document.removeEventListener('keydown', keyHandler); }
function goPrev() { if (currentIndex > 0) loadImage(currentIndex - 1); }
function goNext() { if (currentIndex < allRelpaths.length - 1) loadImage(currentIndex + 1); }
// Events
modal.querySelector('#fs-close').onclick = close;
prevBtn.onclick = (e) => { e.stopPropagation(); goPrev(); };
nextBtn.onclick = (e) => { e.stopPropagation(); goNext(); };
modal.querySelector('#pv-zin').onclick = () => setZoom(zoom + 0.5);
modal.querySelector('#pv-zout').onclick = () => setZoom(zoom - 0.5);
modal.querySelector('#pv-zreset').onclick = resetZoom;
modal.querySelector('#fs-delete').onclick = async () => {
if (!confirm('Foto wirklich löschen?')) return;
try {
await api.deletePhoto(relpath);
await api.deletePhoto(allRelpaths[currentIndex]);
showToast('✓ Gelöscht');
close();
// Parent view neu laden
router.navigate();
} catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
};
modal.querySelector('#fs-sketch').onclick = () => {
close();
openSketchEditor(orderId, url, relpath);
openSketchEditor(orderId, currentUrl, allRelpaths[currentIndex]);
};
// Doppelklick = Zoom Toggle
img.addEventListener('dblclick', (e) => {
e.preventDefault();
if (zoom > 1) resetZoom(); else setZoom(2.5);
});
// Touch: Pinch-to-Zoom + Swipe + Pan
body.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
const t = e.touches[0];
touchState.startX = t.clientX;
touchState.startY = t.clientY;
touchState.startPanX = panX;
touchState.startPanY = panY;
touchState.isDragging = true;
touchState.isPinching = false;
const now = Date.now();
if (now - touchState.lastTap < 300) {
if (zoom > 1) resetZoom(); else setZoom(2.5);
touchState.lastTap = 0;
} else {
touchState.lastTap = now;
}
} else if (e.touches.length === 2) {
e.preventDefault();
touchState.isPinching = true;
touchState.isDragging = false;
touchState.startDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
touchState.startZoom = zoom;
}
}, { passive: false });
body.addEventListener('touchmove', (e) => {
if (touchState.isPinching && e.touches.length === 2) {
e.preventDefault();
const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
setZoom(touchState.startZoom * (dist / touchState.startDist));
} else if (touchState.isDragging && e.touches.length === 1) {
const t = e.touches[0];
const dx = t.clientX - touchState.startX;
const dy = t.clientY - touchState.startY;
if (zoom > 1) {
e.preventDefault();
panX = touchState.startPanX + dx;
panY = touchState.startPanY + dy;
constrainPan();
applyTransform();
}
}
}, { passive: false });
body.addEventListener('touchend', (e) => {
if (touchState.isDragging && zoom <= 1 && e.changedTouches.length > 0) {
const t = e.changedTouches[0];
const dx = t.clientX - touchState.startX;
if (Math.abs(dx) > 50) {
if (dx > 0) goPrev(); else goNext();
}
}
touchState.isDragging = false;
touchState.isPinching = false;
});
// Tastatur
const keyHandler = (e) => {
if (e.key === 'Escape') close();
if (e.key === 'ArrowLeft') goPrev();
if (e.key === 'ArrowRight') goNext();
};
document.addEventListener('keydown', keyHandler);
// Erstes Bild laden
await loadImage(currentIndex);
}
async function openVoiceModal(orderId) {