[deploy] Bild-Viewer mit Pinch-Zoom, Swipe und Navigation
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 0s
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:
parent
645624eeb6
commit
0a9c4d4db3
2 changed files with 230 additions and 10 deletions
76
app.css
76
app.css
|
|
@ -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
164
app.js
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue