diff --git a/README.md b/README.md
index 915f783..9c67bb7 100644
--- a/README.md
+++ b/README.md
@@ -218,3 +218,8 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
## Lizenz
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).
diff --git a/app.css b/app.css
index ce8e524..b12e2db 100644
--- a/app.css
+++ b/app.css
@@ -1533,3 +1533,64 @@ body {
cursor: pointer;
}
.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; }
diff --git a/app.js b/app.js
index bf4c9db..8f47933 100644
--- a/app.js
+++ b/app.js
@@ -2406,16 +2406,20 @@ async function openPageActionsModal(berichtId, pageId, relpath, note, title) {
function openPdfModal(blobUrl, filename) {
const fname = filename || 'bericht.pdf';
const modal = document.createElement('div');
- modal.className = 'fullscreen-modal';
+ modal.className = 'fullscreen-modal pdfv-modal';
modal.innerHTML = `
-
-
+
+
+
+
+
+
`;
document.body.appendChild(modal);
@@ -2428,6 +2432,149 @@ function openPdfModal(blobUrl, filename) {
await shareFile(blob, fname, 'application/pdf', fname);
} 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 = `
`;
+ 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 = '
PDF-Fehler: ' + escapeHtml(e.message || String(e)) + '
';
+ }
+ })();
}
/* ============================================================
@@ -2625,6 +2772,9 @@ router.on('/shipments/:id', async (args) => {
const wrap = document.getElementById('shipment-pdf-wrap');
wrap.innerHTML = '
Lieferschein-Vorschau
';
await renderPdfInline(res.url, wrap, 1);
+ wrap.style.cursor = 'zoom-in';
+ wrap.onclick = () => openPdfModal(res.url, (data.shipment.ref || 'lieferschein') + '.pdf');
+ wrap.insertAdjacentHTML('afterbegin', '
Antippen zum Vergrößern
');
}
} catch (e) {
console.warn('PDF-Vorschau', e);
diff --git a/index.php b/index.php
index 204ec09..f53538c 100644
--- a/index.php
+++ b/index.php
@@ -40,7 +40,7 @@ header('Expires: 0');
-
+
Baustelle