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 = `
- -
📑 PDF-Vorschau
+ +
📑 ${escapeHtml(fname)}
⬇
-
- +
+
+ + + +
`; 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