Zurück-Knopf mit Pfeil und Beschriftung; PDF-Betrachter mit Zoom (Knöpfe, Pinch, Doppeltipp, Strg+Rad) [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 14s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-10-15 21:56:32 +02:00
parent 8e984fca44
commit 55b9a4f2d0
4 changed files with 222 additions and 6 deletions

View file

@ -218,3 +218,8 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
## Lizenz ## Lizenz
GPL v3+ GPL v3+
## PDF-Betrachter und Zurück-Knopf
Der PDF-Betrachter (`openPdfModal`) zeichnet mit PDF.js und zoomt: Knöpfe − / + / „100 %“ (Seitenbreite), Zwei-Finger-Zoom (scharf neu gezeichnet beim Loslassen), Doppeltipp, Strg+Mausrad, Seitenanzeige. Die Lieferschein-Vorschau öffnet ihn per Tipp. Der Zurück-Knopf oben links zeigt Pfeil und das Wort „Zurück" (44 px Tippfläche).

61
app.css
View file

@ -1533,3 +1533,64 @@ body {
cursor: pointer; cursor: pointer;
} }
.shared-banner:active { transform: scale(0.98); } .shared-banner:active { transform: scale(0.98); }
/* Zurück-Knopf: Pfeil UND Wort, ausreichend große Tippfläche (Handschuhe) */
.back-btn {
display: inline-flex;
align-items: center;
gap: 2px;
min-height: 44px;
padding: 0 14px 0 6px;
margin-right: 6px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.18);
border-radius: 22px;
color: #f0f0f0;
font: inherit;
font-size: 15px;
font-weight: 600;
cursor: pointer;
-webkit-appearance: none;
}
.back-btn:active { background: rgba(255,255,255,0.2); }
.back-btn[style*="display: none"] { display: none !important; }
/* PDF-Betrachter mit Zoom */
.pdfv-modal { background: #222; }
.pdfv-scroll {
flex: 1;
overflow: auto;
background: #3a3a3a;
padding: 8px 0;
touch-action: pan-x pan-y;
-webkit-overflow-scrolling: touch;
}
.pdfv-stack { margin: 0 auto; }
.pdfv-page {
display: block;
background: #fff;
margin: 0 auto 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
}
.pdfv-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(0,0,0,0.85);
border-top: 1px solid #333;
}
.pdfv-btn {
min-width: 52px;
min-height: 48px;
background: #2a2a30;
color: #fff;
border: 1px solid #555;
border-radius: 8px;
font-size: 22px;
cursor: pointer;
}
.pdfv-btn.pdfv-wide { font-size: 15px; min-width: 84px; }
.pdfv-info { margin-left: auto; color: #bbb; font-size: 13px; }
.pdf-inline-wrap canvas { cursor: zoom-in; }

160
app.js
View file

@ -2406,16 +2406,20 @@ async function openPageActionsModal(berichtId, pageId, relpath, note, title) {
function openPdfModal(blobUrl, filename) { function openPdfModal(blobUrl, filename) {
const fname = filename || 'bericht.pdf'; const fname = filename || 'bericht.pdf';
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'fullscreen-modal'; modal.className = 'fullscreen-modal pdfv-modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="fs-header"> <div class="fs-header">
<button class="icon-btn" id="pdf-close">✕</button> <button class="icon-btn pdfv-close" id="pdf-close" aria-label="Schließen">✕</button>
<div class="fs-title">📑 PDF-Vorschau</div> <div class="fs-title" id="pdfv-title">📑 ${escapeHtml(fname)}</div>
<button class="icon-btn" id="pdf-share" title="Teilen">📤</button> <button class="icon-btn" id="pdf-share" title="Teilen">📤</button>
<a class="icon-btn" id="pdf-download" href="${blobUrl}" download="${escapeHtml(fname)}" title="Download">⬇</a> <a class="icon-btn" id="pdf-download" href="${blobUrl}" download="${escapeHtml(fname)}" title="Download">⬇</a>
</div> </div>
<div class="fs-body" style="padding:0;"> <div class="pdfv-scroll" id="pdfv-scroll"><div class="pdfv-stack" id="pdfv-stack"></div></div>
<iframe src="${blobUrl}" style="width:100%;height:100%;border:none;background:#444;"></iframe> <div class="pdfv-bar">
<button class="pdfv-btn" id="pdfv-minus" aria-label="Verkleinern">−</button>
<button class="pdfv-btn pdfv-wide" id="pdfv-fit" title="Auf Breite">100 %</button>
<button class="pdfv-btn" id="pdfv-plus" aria-label="Vergrößern">+</button>
<span class="pdfv-info" id="pdfv-info"></span>
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
@ -2428,6 +2432,149 @@ function openPdfModal(blobUrl, filename) {
await shareFile(blob, fname, 'application/pdf', fname); await shareFile(blob, fname, 'application/pdf', fname);
} catch (e) { showToast('Teilen fehlgeschlagen: ' + e.message, 'error'); } } catch (e) { showToast('Teilen fehlgeschlagen: ' + e.message, 'error'); }
}; };
const scroller = modal.querySelector('#pdfv-scroll');
const stack = modal.querySelector('#pdfv-stack');
const info = modal.querySelector('#pdfv-info');
const fitBtn = modal.querySelector('#pdfv-fit');
if (typeof pdfjs === 'undefined') {
// Ohne PDF.js bleibt der Browser-Betrachter
scroller.innerHTML = `<iframe src="${blobUrl}" style="width:100%;height:100%;border:none;background:#444;"></iframe>`;
modal.querySelector('.pdfv-bar').style.display = 'none';
return;
}
const ZMIN = 0.5, ZMAX = 5;
let doc = null, zoom = 1, baseW = 0, pages = [], lauf = 0;
const fitWidth = () => Math.max(200, scroller.clientWidth - 16);
async function render() {
const mein = ++lauf;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssW = Math.round(baseW * zoom);
stack.style.width = cssW + 'px';
fitBtn.textContent = Math.round(zoom * 100) + ' %';
for (const pg of pages) {
if (mein !== lauf) return;
const vp1 = pg.page.getViewport({ scale: 1 });
const cssScale = cssW / vp1.width;
pg.canvas.style.width = cssW + 'px';
pg.canvas.style.height = Math.round(vp1.height * cssScale) + 'px';
// Scharf neu zeichnen, aber nur wenn sich die Auflösung wirklich ändert
let px = cssScale * dpr;
const maxSide = 4096;
px = Math.min(px, maxSide / Math.max(vp1.width, vp1.height));
const w = Math.round(vp1.width * px), h = Math.round(vp1.height * px);
if (pg.w === w && pg.h === h) continue;
const tmp = document.createElement('canvas');
tmp.width = w; tmp.height = h;
await pg.page.render({ canvasContext: tmp.getContext('2d'), viewport: pg.page.getViewport({ scale: px }) }).promise;
if (mein !== lauf) return;
pg.canvas.width = w; pg.canvas.height = h;
pg.canvas.getContext('2d').drawImage(tmp, 0, 0);
pg.w = w; pg.h = h;
}
}
/** Zoom setzen; die Stelle unter (fx, fy) im Betrachter bleibt stehen. */
function setZoom(neu, fx, fy) {
neu = Math.min(ZMAX, Math.max(ZMIN, neu));
if (Math.abs(neu - zoom) < 0.001) return;
if (fx === undefined) { fx = scroller.clientWidth / 2; fy = scroller.clientHeight / 2; }
const relX = (scroller.scrollLeft + fx) / Math.max(1, stack.offsetWidth);
const relY = (scroller.scrollTop + fy) / Math.max(1, stack.offsetHeight);
zoom = neu;
stack.style.transform = '';
render();
// Größe steht sofort fest (CSS), die Schärfe folgt
scroller.scrollLeft = relX * stack.offsetWidth - fx;
scroller.scrollTop = relY * stack.offsetHeight - fy;
}
modal.querySelector('#pdfv-plus').onclick = () => setZoom(zoom * 1.25);
modal.querySelector('#pdfv-minus').onclick = () => setZoom(zoom / 1.25);
fitBtn.onclick = () => setZoom(1);
// Strg + Mausrad (Desktop)
scroller.addEventListener('wheel', (e) => {
if (!e.ctrlKey) return;
e.preventDefault();
const r = scroller.getBoundingClientRect();
setZoom(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1), e.clientX - r.left, e.clientY - r.top);
}, { passive: false });
// Zwei Finger: Vorschau per transform, scharf gezeichnet wird beim Loslassen
let pinch = null;
const dist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
scroller.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
const r = scroller.getBoundingClientRect();
pinch = {
d0: dist(e.touches), ratio: 1,
fx: (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left,
fy: (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top,
};
stack.style.transformOrigin = (scroller.scrollLeft + pinch.fx) + 'px ' + (scroller.scrollTop + pinch.fy) + 'px';
}
}, { passive: true });
scroller.addEventListener('touchmove', (e) => {
if (!pinch || e.touches.length !== 2) return;
e.preventDefault();
pinch.ratio = Math.min(ZMAX / zoom, Math.max(ZMIN / zoom, dist(e.touches) / pinch.d0));
stack.style.transform = 'scale(' + pinch.ratio + ')';
}, { passive: false });
const pinchEnde = () => {
if (!pinch) return;
const p = pinch; pinch = null;
// Mit Vorschau-Skalierung: erst zurücksetzen, dann echt vergrößern (Anker bleibt)
stack.style.transform = '';
setZoom(zoom * p.ratio, p.fx, p.fy);
};
scroller.addEventListener('touchend', (e) => { if (e.touches.length < 2) pinchEnde(); });
scroller.addEventListener('touchcancel', pinchEnde);
// Doppeltipp: zwischen Seitenbreite und 2,5-fach wechseln
let letzterTipp = 0;
scroller.addEventListener('touchend', (e) => {
if (e.touches.length || e.changedTouches.length !== 1 || pinch) return;
const jetzt = Date.now();
if (jetzt - letzterTipp < 300) {
const r = scroller.getBoundingClientRect();
const t = e.changedTouches[0];
setZoom(zoom > 1.2 ? 1 : 2.5, t.clientX - r.left, t.clientY - r.top);
letzterTipp = 0;
} else { letzterTipp = jetzt; }
});
// Seitenanzeige
scroller.addEventListener('scroll', () => {
if (!pages.length) return;
const mitte = scroller.scrollTop + scroller.clientHeight / 3;
let nr = 1;
pages.forEach((pg, i) => { if (pg.canvas.offsetTop <= mitte) nr = i + 1; });
info.textContent = 'Seite ' + nr + ' / ' + pages.length;
}, { passive: true });
(async () => {
try {
const buf = await fetch(blobUrl).then(r => r.arrayBuffer());
doc = await pdfjs.getDocument({ data: buf }).promise;
baseW = fitWidth();
for (let i = 1; i <= doc.numPages; i++) {
const page = await doc.getPage(i);
const canvas = document.createElement('canvas');
canvas.className = 'pdfv-page';
stack.appendChild(canvas);
pages.push({ page, canvas, w: 0, h: 0 });
}
info.textContent = 'Seite 1 / ' + doc.numPages;
await render();
} catch (e) {
scroller.innerHTML = '<p style="color:#fff;padding:16px;">PDF-Fehler: ' + escapeHtml(e.message || String(e)) + '</p>';
}
})();
} }
/* ============================================================ /* ============================================================
@ -2625,6 +2772,9 @@ router.on('/shipments/:id', async (args) => {
const wrap = document.getElementById('shipment-pdf-wrap'); const wrap = document.getElementById('shipment-pdf-wrap');
wrap.innerHTML = '<div class="pdf-inline-hint">Lieferschein-Vorschau</div>'; wrap.innerHTML = '<div class="pdf-inline-hint">Lieferschein-Vorschau</div>';
await renderPdfInline(res.url, wrap, 1); await renderPdfInline(res.url, wrap, 1);
wrap.style.cursor = 'zoom-in';
wrap.onclick = () => openPdfModal(res.url, (data.shipment.ref || 'lieferschein') + '.pdf');
wrap.insertAdjacentHTML('afterbegin', '<div class="pdf-inline-hint">Antippen zum Vergrößern</div>');
} }
} catch (e) { } catch (e) {
console.warn('PDF-Vorschau', e); console.warn('PDF-Vorschau', e);

View file

@ -40,7 +40,7 @@ header('Expires: 0');
<div id="app"> <div id="app">
<header id="topbar"> <header id="topbar">
<button id="back-btn" class="icon-btn" style="display:none">←</button> <button id="back-btn" class="back-btn" style="display:none" aria-label="Zurück"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Zurück</span></button>
<h1 id="page-title">Baustelle</h1> <h1 id="page-title">Baustelle</h1>
<button id="install-btn" class="icon-btn" title="Als App installieren" style="display:none">📲</button> <button id="install-btn" class="icon-btn" title="Als App installieren" style="display:none">📲</button>
<button id="help-btn" class="icon-btn" title="Hilfe">❓</button> <button id="help-btn" class="icon-btn" title="Hilfe">❓</button>