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
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:
parent
8e984fca44
commit
55b9a4f2d0
4 changed files with 222 additions and 6 deletions
|
|
@ -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).
|
||||
|
|
|
|||
61
app.css
61
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; }
|
||||
|
|
|
|||
160
app.js
160
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 = `
|
||||
<div class="fs-header">
|
||||
<button class="icon-btn" id="pdf-close">✕</button>
|
||||
<div class="fs-title">📑 PDF-Vorschau</div>
|
||||
<button class="icon-btn pdfv-close" id="pdf-close" aria-label="Schließen">✕</button>
|
||||
<div class="fs-title" id="pdfv-title">📑 ${escapeHtml(fname)}</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="fs-body" style="padding:0;">
|
||||
<iframe src="${blobUrl}" style="width:100%;height:100%;border:none;background:#444;"></iframe>
|
||||
<div class="pdfv-scroll" id="pdfv-scroll"><div class="pdfv-stack" id="pdfv-stack"></div></div>
|
||||
<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>
|
||||
`;
|
||||
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 = `<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');
|
||||
wrap.innerHTML = '<div class="pdf-inline-hint">Lieferschein-Vorschau</div>';
|
||||
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) {
|
||||
console.warn('PDF-Vorschau', e);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ header('Expires: 0');
|
|||
|
||||
<div id="app">
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue