PWA: Bild-Viewer mit Pinch-Zoom und Swipe [deploy]
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
- Scanner-Seiten können vor Upload gezoomt werden - Pinch-to-Zoom (2 Finger) - Doppeltap für Zoom-Toggle - Swipe links/rechts für Navigation - Vollbild-Overlay mit Schliessen-Button Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7c21446f98
commit
1512e4d706
1 changed files with 317 additions and 0 deletions
|
|
@ -514,6 +514,123 @@ body {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== Bild-Viewer (Zoom + Swipe) ========== */
|
||||||
|
.pwa-viewer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.95);
|
||||||
|
z-index: 99999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.pwa-viewer-overlay.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.pwa-viewer-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pwa-viewer-image {
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
object-fit: contain;
|
||||||
|
transform-origin: center center;
|
||||||
|
transition: transform 0.15s ease-out;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.pwa-viewer-image.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.pwa-viewer-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.pwa-viewer-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 50px;
|
||||||
|
height: 70px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.pwa-viewer-nav:disabled { opacity: 0.3; }
|
||||||
|
.pwa-viewer-nav.prev { left: 8px; border-radius: 4px; }
|
||||||
|
.pwa-viewer-nav.next { right: 8px; border-radius: 4px; }
|
||||||
|
.pwa-viewer-counter {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.pwa-viewer-zoom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.pwa-viewer-zoom button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hochgeladene Bilder klickbar */
|
||||||
|
.uploaded-item {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.uploaded-item:active {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scanner-Thumbnails klickbar */
|
||||||
|
.scanner-page-thumb {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -1075,6 +1192,206 @@ function toast(msg, isError) {
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === BILD-VIEWER mit Zoom und Swipe ===
|
||||||
|
const pwaViewer = (function() {
|
||||||
|
let images = [];
|
||||||
|
let currentIndex = 0;
|
||||||
|
let zoom = 1;
|
||||||
|
let panX = 0, panY = 0;
|
||||||
|
let overlay, container, img, closeBtn, prevBtn, nextBtn, counter;
|
||||||
|
let touchState = { startX: 0, startY: 0, startDist: 0, startZoom: 1, startPanX: 0, startPanY: 0, isDragging: false, isPinching: false, lastTap: 0 };
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
// DOM erstellen
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.className = 'pwa-viewer-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="pwa-viewer-container">
|
||||||
|
<img class="pwa-viewer-image" src="" draggable="false">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pwa-viewer-close">×</button>
|
||||||
|
<button type="button" class="pwa-viewer-nav prev">‹</button>
|
||||||
|
<button type="button" class="pwa-viewer-nav next">›</button>
|
||||||
|
<div class="pwa-viewer-counter">1 / 1</div>
|
||||||
|
<div class="pwa-viewer-zoom">
|
||||||
|
<button data-action="out">−</button>
|
||||||
|
<button data-action="reset">⟳</button>
|
||||||
|
<button data-action="in">+</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
container = overlay.querySelector('.pwa-viewer-container');
|
||||||
|
img = overlay.querySelector('.pwa-viewer-image');
|
||||||
|
closeBtn = overlay.querySelector('.pwa-viewer-close');
|
||||||
|
prevBtn = overlay.querySelector('.pwa-viewer-nav.prev');
|
||||||
|
nextBtn = overlay.querySelector('.pwa-viewer-nav.next');
|
||||||
|
counter = overlay.querySelector('.pwa-viewer-counter');
|
||||||
|
|
||||||
|
// Events
|
||||||
|
closeBtn.onclick = close;
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay || e.target === container) close(); };
|
||||||
|
prevBtn.onclick = (e) => { e.stopPropagation(); prev(); };
|
||||||
|
nextBtn.onclick = (e) => { e.stopPropagation(); next(); };
|
||||||
|
|
||||||
|
overlay.querySelector('.pwa-viewer-zoom').onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const a = e.target.dataset.action;
|
||||||
|
if (a === 'in') setZoom(zoom + 0.5);
|
||||||
|
else if (a === 'out') setZoom(zoom - 0.5);
|
||||||
|
else if (a === 'reset') resetZoom();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch
|
||||||
|
container.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||||
|
container.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
container.addEventListener('touchend', handleTouchEnd);
|
||||||
|
|
||||||
|
// Doppelklick
|
||||||
|
img.addEventListener('dblclick', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (zoom > 1) resetZoom();
|
||||||
|
else setZoom(2.5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(imgs, idx) {
|
||||||
|
images = imgs;
|
||||||
|
currentIndex = Math.max(0, Math.min(idx || 0, imgs.length - 1));
|
||||||
|
resetZoom();
|
||||||
|
loadCurrent();
|
||||||
|
overlay.classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() { if (currentIndex < images.length - 1) { currentIndex++; resetZoom(); loadCurrent(); } }
|
||||||
|
function prev() { if (currentIndex > 0) { currentIndex--; resetZoom(); loadCurrent(); } }
|
||||||
|
|
||||||
|
function loadCurrent() {
|
||||||
|
img.src = images[currentIndex].url;
|
||||||
|
counter.textContent = (currentIndex + 1) + ' / ' + images.length;
|
||||||
|
prevBtn.disabled = currentIndex === 0;
|
||||||
|
nextBtn.disabled = currentIndex === images.length - 1;
|
||||||
|
prevBtn.style.display = nextBtn.style.display = images.length > 1 ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setZoom(z) {
|
||||||
|
zoom = Math.max(0.5, Math.min(5, z));
|
||||||
|
constrainPan();
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom() { zoom = 1; panX = 0; panY = 0; applyTransform(); }
|
||||||
|
|
||||||
|
function constrainPan() {
|
||||||
|
if (zoom <= 1) { panX = 0; panY = 0; return; }
|
||||||
|
const rect = container.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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransform() {
|
||||||
|
img.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(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;
|
||||||
|
// Doppeltap
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(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) prev(); else next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
touchState.isDragging = false;
|
||||||
|
touchState.isPinching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
return { open, close };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Hochgeladene Bilder klickbar machen
|
||||||
|
uploadedList.addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.uploaded-item');
|
||||||
|
if (!item) return;
|
||||||
|
// Alle Bilder in der Liste sammeln
|
||||||
|
const items = Array.from(uploadedList.querySelectorAll('.uploaded-item'));
|
||||||
|
const images = items.map(it => {
|
||||||
|
const name = it.querySelector('.name')?.textContent || '';
|
||||||
|
// Bild-URL aus dem Upload-Ordner (über Token-API holen wäre besser, aber als Fallback filename)
|
||||||
|
return { url: '', title: name };
|
||||||
|
}).filter(i => i.title);
|
||||||
|
|
||||||
|
// Da wir die URL nicht haben, zeigen wir nur eine Meldung
|
||||||
|
// Die Bilder sind erst nach Upload auf dem Server, nicht mehr lokal verfügbar
|
||||||
|
toast('Bilder nur im Dolibarr-Editor ansehbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scanner-Thumbs klickbar machen (diese haben noch die lokale URL)
|
||||||
|
scannerPagesStrip.addEventListener('click', (e) => {
|
||||||
|
const thumb = e.target.closest('.scanner-page-thumb');
|
||||||
|
if (!thumb) return;
|
||||||
|
|
||||||
|
// Alle Scanner-Seiten als Bilder
|
||||||
|
const images = scannedPages.map((p, i) => ({ url: p.url, title: 'Seite ' + (i + 1) }));
|
||||||
|
const idx = Array.from(scannerPagesStrip.querySelectorAll('.scanner-page-thumb')).indexOf(thumb);
|
||||||
|
if (images.length > 0) {
|
||||||
|
pwaViewer.open(images, idx >= 0 ? idx : 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue