/* * Bericht-Editor — PDF.js + Fabric.js + SortableJS * Lädt Seiten via /ajax/page_image.php (Bilder direkt, PDFs via PDF.js gerendert), * legt Fabric.js-Canvas darüber, speichert Annotationen pro Seite über Ajax. * * Erwartet im DOM: * #pdf-canvas — Canvas für die Seitendarstellung * #fabric-canvas — Overlay-Canvas für Annotationen * #bericht-page-list — Container für Seiten-Thumbnails (.page-thumb[data-pageid]) * #page-note — Textarea für Seiten-Notiz * .att-check — Checkboxen der Anhänge-Liste * #btn-add-selected — Button "Auswahl in Bericht übernehmen" * #btn-save-draft — Entwurf speichern * #btn-finalize — Bericht finalisieren * #btn-undo / #btn-redo / #btn-delete-selected * .tool-btn[data-tool] * #tool-color, #tool-stroke * #bericht-extra-upload (file input) * * Globale Konfiguration: window.BERICHT_CONFIG (vom PHP gesetzt) */ (function () { 'use strict'; const cfg = window.BERICHT_CONFIG || {}; if (!cfg.berichtid) { console.warn('Bericht: keine Konfiguration'); return; } // PDF.js worker (lokal) if (window.pdfjsLib) { pdfjsLib.GlobalWorkerOptions.workerSrc = '/bericht/js/lib/pdf.worker.min.js'; } let currentPageId = null; let currentPageEl = null; let currentPageRotation = 0; // 0 / 90 / 180 / 270 let currentPageBuffer = null; // ArrayBuffer der aktuellen Quelle let currentPageMime = ''; let fabricCanvas = null; const pdfCanvas = document.getElementById('pdf-canvas'); let currentTool = 'select'; /* ---------- Init ---------- */ function init() { // Fabric initialisieren (wird beim ersten Seitenrendern dimensioniert) fabricCanvas = new fabric.Canvas('fabric-canvas', { isDrawingMode: false, selection: true, }); fabricCanvas.freeDrawingBrush.color = document.getElementById('tool-color').value; fabricCanvas.freeDrawingBrush.width = parseInt(document.getElementById('tool-stroke').value, 10); // Erste Seite laden (wenn vorhanden) const firstThumb = document.querySelector('#bericht-page-list .page-thumb'); if (firstThumb) loadPage(firstThumb); bindThumbs(); bindToolbar(); bindAttachments(); bindExtraUpload(); bindActions(); bindSortable(); } /* ---------- Seiten laden ---------- */ async function loadPage(thumbEl) { // vorher: aktuelle Seite speichern if (currentPageId) await savePageAnnotations(false); currentPageEl = thumbEl; currentPageId = parseInt(thumbEl.dataset.pageid, 10); document.querySelectorAll('.page-thumb.active').forEach(e => e.classList.remove('active')); thumbEl.classList.add('active'); const url = cfg.urls.page_image + '?pageid=' + currentPageId; const resp = await fetch(url); const ct = resp.headers.get('Content-Type') || ''; const buf = await resp.arrayBuffer(); currentPageBuffer = buf; currentPageMime = ct; currentPageRotation = 0; // wird gleich aus loadPageMeta überschrieben falls gespeichert fabricCanvas.clear(); document.getElementById('page-note').value = ''; await loadPageMeta(); // setzt currentPageRotation falls vorhanden await rerenderCurrent(); // rendert mit Rotation } /** * Rendert die aktuelle Seite (image oder pdf) aus dem Buffer mit currentPageRotation. */ async function rerenderCurrent() { if (!currentPageBuffer) return; if (currentPageMime.includes('pdf')) { await renderPdf(currentPageBuffer); } else if (currentPageMime.includes('image')) { await renderImage(currentPageBuffer, currentPageMime); } } /** * Liefert die nutzbare Breite des Canvas-Containers (minus Padding). * Begrenzt auf 1200px, damit auch auf großen Screens nicht alles riesig wird. */ function getTargetCanvasWidth() { const wrap = document.querySelector('.bericht-canvas-wrap'); if (!wrap) return 800; const cs = getComputedStyle(wrap); const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight); const avail = wrap.clientWidth - padX - 4; // 4px Sicherheitsabstand return Math.max(300, Math.min(1200, Math.floor(avail))); } async function renderPdf(arrayBuffer) { if (!window.pdfjsLib) { console.error('PDF.js nicht geladen'); return; } // Buffer kopieren — pdf.js konsumiert den ArrayBuffer beim ersten Aufruf const pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer.slice(0) }).promise; const pageNum = 1; const page = await pdfDoc.getPage(pageNum); // Bei Rotation müssen wir die orientierte Breite messen, um auf den // Container zu passen const target = getTargetCanvasWidth(); const baseViewport = page.getViewport({ scale: 1, rotation: currentPageRotation }); const scale = target / baseViewport.width; const viewport = page.getViewport({ scale: scale, rotation: currentPageRotation }); pdfCanvas.width = viewport.width; pdfCanvas.height = viewport.height; const ctx = pdfCanvas.getContext('2d'); await page.render({ canvasContext: ctx, viewport: viewport }).promise; resizeFabricToCanvas(); } async function renderImage(arrayBuffer, mime) { const blob = new Blob([arrayBuffer], { type: mime }); const url = URL.createObjectURL(blob); await new Promise((res) => { const img = new Image(); img.onload = () => { const target = getTargetCanvasWidth(); // Bei 90/270° werden Breite und Höhe getauscht const rotated = (currentPageRotation === 90 || currentPageRotation === 270); const srcW = rotated ? img.height : img.width; const srcH = rotated ? img.width : img.height; const ratio = target / srcW; pdfCanvas.width = Math.round(srcW * ratio); pdfCanvas.height = Math.round(srcH * ratio); const ctx = pdfCanvas.getContext('2d'); ctx.clearRect(0, 0, pdfCanvas.width, pdfCanvas.height); ctx.save(); ctx.translate(pdfCanvas.width / 2, pdfCanvas.height / 2); ctx.rotate(currentPageRotation * Math.PI / 180); const dw = img.width * ratio; const dh = img.height * ratio; ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh); ctx.restore(); URL.revokeObjectURL(url); res(); }; img.src = url; }); resizeFabricToCanvas(); } function resizeFabricToCanvas() { // Die tatsächlich angezeigte Größe (nicht der Drawing-Buffer) bestimmt // die Position des Fabric-Overlays. Wir setzen das Fabric-Canvas auf // die Buffer-Größe (für scharfe Annotationen) und positionieren es // exakt über dem sichtbaren PDF-Canvas. fabricCanvas.setWidth(pdfCanvas.width); fabricCanvas.setHeight(pdfCanvas.height); // Warten bis der Browser die neue Größe applied hat requestAnimationFrame(() => { const rect = pdfCanvas.getBoundingClientRect(); const wrap = pdfCanvas.parentElement; const wrapRect = wrap.getBoundingClientRect(); const fc = document.getElementById('fabric-canvas'); fc.style.position = 'absolute'; fc.style.left = (rect.left - wrapRect.left + wrap.scrollLeft) + 'px'; fc.style.top = (rect.top - wrapRect.top + wrap.scrollTop) + 'px'; fc.style.width = pdfCanvas.clientWidth + 'px'; fc.style.height = pdfCanvas.clientHeight + 'px'; }); } async function loadPageMeta() { try { const r = await fetch(cfg.urls.save_annotations.replace('save_annotations', 'page_meta') + '?pageid=' + currentPageId); if (!r.ok) return; const data = await r.json(); if (data.fabric_json) { fabricCanvas.loadFromJSON(data.fabric_json, () => fabricCanvas.renderAll()); } if (data.note) document.getElementById('page-note').value = data.note; if (typeof data.rotation !== 'undefined' && data.rotation !== null) { currentPageRotation = parseInt(data.rotation, 10) || 0; } } catch (e) { /* ok */ } } /* ---------- Toolbar ---------- */ function bindToolbar() { document.querySelectorAll('.tool-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tool-btn.active').forEach(b => b.classList.remove('active')); btn.classList.add('active'); currentTool = btn.dataset.tool; applyTool(); }); }); document.getElementById('tool-color').addEventListener('input', e => { fabricCanvas.freeDrawingBrush.color = e.target.value; const sel = fabricCanvas.getActiveObject(); if (sel) { sel.set({ stroke: e.target.value }); if (sel.type === 'i-text' || sel.type === 'text') sel.set({ fill: e.target.value }); fabricCanvas.requestRenderAll(); } }); document.getElementById('tool-stroke').addEventListener('input', e => { fabricCanvas.freeDrawingBrush.width = parseInt(e.target.value, 10); const sel = fabricCanvas.getActiveObject(); if (sel) { sel.set({ strokeWidth: parseInt(e.target.value, 10) }); fabricCanvas.requestRenderAll(); } }); document.getElementById('btn-undo').addEventListener('click', undo); document.getElementById('btn-redo').addEventListener('click', redo); document.getElementById('btn-delete-selected').addEventListener('click', () => { const sel = fabricCanvas.getActiveObjects(); sel.forEach(o => fabricCanvas.remove(o)); fabricCanvas.discardActiveObject(); fabricCanvas.requestRenderAll(); }); // Seitenrotation: rotate-left/right rotieren die SEITE (nicht das Fabric-Objekt) document.getElementById('btn-rotate-left').addEventListener('click', () => rotatePage(-90)); document.getElementById('btn-rotate-right').addEventListener('click', () => rotatePage(90)); } async function rotatePage(deg) { currentPageRotation = ((currentPageRotation + deg) % 360 + 360) % 360; // Annotationen für die alte Orientierung sind im JSON noch da — wir werfen // sie vor dem Re-Render weg, damit sie nicht falsch positioniert sind. // (Bewusste Designentscheidung: rotieren vor dem Annotieren.) fabricCanvas.clear(); await rerenderCurrent(); // Sofort speichern, damit Rotation persistent ist await savePageAnnotations(false); } function applyTool() { fabricCanvas.isDrawingMode = (currentTool === 'draw'); fabricCanvas.selection = (currentTool === 'select'); // Click-Handler für Shape-Tools fabricCanvas.off('mouse:down', shapeDown); if (['rect', 'circle', 'arrow', 'text'].includes(currentTool)) { fabricCanvas.on('mouse:down', shapeDown); } } function shapeDown(opt) { const p = fabricCanvas.getPointer(opt.e); const color = document.getElementById('tool-color').value; const sw = parseInt(document.getElementById('tool-stroke').value, 10); let shape = null; if (currentTool === 'rect') { shape = new fabric.Rect({ left: p.x, top: p.y, width: 100, height: 60, fill: 'transparent', stroke: color, strokeWidth: sw }); } else if (currentTool === 'circle') { shape = new fabric.Circle({ left: p.x, top: p.y, radius: 40, fill: 'transparent', stroke: color, strokeWidth: sw }); } else if (currentTool === 'arrow') { // Pfeil = Linie mit Pfeilspitze (vereinfacht) shape = new fabric.Line([p.x, p.y, p.x + 100, p.y + 50], { stroke: color, strokeWidth: sw }); } else if (currentTool === 'text') { shape = new fabric.IText('Text…', { left: p.x, top: p.y, fontSize: 24, fill: color }); } if (shape) { fabricCanvas.add(shape); fabricCanvas.setActiveObject(shape); // Nach dem Setzen wieder zurück auf Select, damit User direkt verschieben/skalieren kann const sb = document.querySelector('.tool-btn[data-tool="select"]'); if (sb) sb.click(); } } /* ---------- Undo/Redo (einfach) ---------- */ const history = []; let histIdx = -1; function snapshot() { history.length = histIdx + 1; history.push(JSON.stringify(fabricCanvas.toJSON())); histIdx = history.length - 1; } function undo() { if (histIdx <= 0) return; histIdx--; fabricCanvas.loadFromJSON(history[histIdx], () => fabricCanvas.renderAll()); } function redo() { if (histIdx >= history.length - 1) return; histIdx++; fabricCanvas.loadFromJSON(history[histIdx], () => fabricCanvas.renderAll()); } setTimeout(() => { if (fabricCanvas) { fabricCanvas.on('object:added', snapshot); fabricCanvas.on('object:modified', snapshot); fabricCanvas.on('object:removed', snapshot); } }, 500); function rotateCurrent(deg) { const sel = fabricCanvas.getActiveObject(); if (sel) { sel.rotate(((sel.angle || 0) + deg) % 360); fabricCanvas.requestRenderAll(); } } /* ---------- Speichern ---------- */ async function savePageAnnotations(showMessage = true) { if (!currentPageId) return; const fd = new FormData(); fd.append('token', cfg.token); fd.append('pageid', currentPageId); fd.append('fabric_json', JSON.stringify(fabricCanvas.toJSON())); fd.append('note', document.getElementById('page-note').value || ''); fd.append('rotation', currentPageRotation); const r = await fetch(cfg.urls.save_annotations, { method: 'POST', body: fd }); const data = await r.json().catch(() => ({})); if (showMessage && data.success) toast('Seite gespeichert'); } function bindActions() { document.getElementById('btn-save-draft').addEventListener('click', async () => { await savePageAnnotations(true); }); document.getElementById('btn-finalize').addEventListener('click', async () => { await savePageAnnotations(false); const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); const r = await fetch(cfg.urls.generate_pdf, { method: 'POST', body: fd }); const data = await r.json(); if (data.success) { toast('PDF erstellt: ' + data.filename); setTimeout(() => location.reload(), 1500); } else { alert('Fehler: ' + (data.error || 'unbekannt')); } }); } /* ---------- Anhänge & Thumbs ---------- */ function bindAttachments() { const btn = document.getElementById('btn-add-selected'); if (!btn) return; btn.addEventListener('click', async () => { const checks = document.querySelectorAll('.att-check:checked'); for (const c of checks) { const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); fd.append('relpath', c.dataset.relpath); fd.append('mime', c.dataset.mime); await fetch(cfg.urls.add_attachment, { method: 'POST', body: fd }); c.checked = false; } location.reload(); // einfacher als Thumbnail-Liste neu zu rendern }); } function bindExtraUpload() { const inp = document.getElementById('bericht-extra-upload'); if (!inp) return; inp.addEventListener('change', async () => { if (!inp.files.length) return; const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); fd.append('file', inp.files[0]); const r = await fetch(cfg.urls.upload_extra, { method: 'POST', body: fd }); const data = await r.json(); if (data.success) location.reload(); else alert('Upload fehlgeschlagen: ' + (data.error || '')); }); } function bindThumbs() { document.querySelectorAll('.page-thumb').forEach(t => { t.addEventListener('click', e => { if (e.target.closest('.thumb-del')) return; loadPage(t); }); const del = t.querySelector('.thumb-del'); if (del) del.addEventListener('click', async (e) => { e.stopPropagation(); if (!confirm(cfg.lang.confirm_del)) return; const fd = new FormData(); fd.append('token', cfg.token); fd.append('pageid', t.dataset.pageid); await fetch(cfg.urls.delete_page, { method: 'POST', body: fd }); location.reload(); }); }); } function bindSortable() { const list = document.getElementById('bericht-page-list'); if (!list || !window.Sortable) return; Sortable.create(list, { animation: 150, onEnd: async () => { const ids = Array.from(list.querySelectorAll('.page-thumb')).map(t => t.dataset.pageid); const fd = new FormData(); fd.append('token', cfg.token); fd.append('order', JSON.stringify(ids)); await fetch(cfg.urls.reorder_pages, { method: 'POST', body: fd }); } }); } /* ---------- Helpers ---------- */ function toast(msg) { const t = document.createElement('div'); t.className = 'bericht-toast'; t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 2000); } document.addEventListener('DOMContentLoaded', init); })();