/* * 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 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(); fabricCanvas.clear(); document.getElementById('page-note').value = ''; if (ct.includes('pdf')) { await renderPdf(buf); } else if (ct.includes('image')) { await renderImage(buf, ct); } // Vorhandene Annotationen laden — kommt über das Thumb-Dataset oder einen Refetch // (Für Einfachheit: hier ein extra Ajax wäre sauberer; wir nehmen an, der Server // liefert die Annotationen einmal beim Seitenwechsel mit.) await loadPageMeta(); } async function renderPdf(arrayBuffer) { if (!window.pdfjsLib) { console.error('PDF.js nicht geladen'); return; } const pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; // Mehrseitige PDFs werden serverseitig pro Seite als eigene bericht_page abgelegt, // hier rendern wir immer Seite 1 dieses Source-PDFs — die Original-Seitennummer // (source_page) kümmert sich um die Auswahl beim Finalisieren. const pageNum = 1; const page = await pdfDoc.getPage(pageNum); const viewport = page.getViewport({ scale: 1.5 }); 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 = () => { // Skalierung auf max 1200px Breite const maxW = 1200; const ratio = img.width > maxW ? maxW / img.width : 1; pdfCanvas.width = img.width * ratio; pdfCanvas.height = img.height * ratio; const ctx = pdfCanvas.getContext('2d'); ctx.clearRect(0, 0, pdfCanvas.width, pdfCanvas.height); ctx.drawImage(img, 0, 0, pdfCanvas.width, pdfCanvas.height); URL.revokeObjectURL(url); res(); }; img.src = url; }); resizeFabricToCanvas(); } function resizeFabricToCanvas() { fabricCanvas.setWidth(pdfCanvas.width); fabricCanvas.setHeight(pdfCanvas.height); // Overlay positionieren const fc = document.getElementById('fabric-canvas'); fc.style.position = 'absolute'; fc.style.left = pdfCanvas.offsetLeft + 'px'; fc.style.top = pdfCanvas.offsetTop + 'px'; } async function loadPageMeta() { // Annotationen + Notiz für aktuelle Seite holen try { const r = await fetch(cfg.urls.save_annotations.replace('save_annotations', 'page_meta') + '?pageid=' + currentPageId); // Falls page_meta.php nicht existiert: still bleiben 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; } 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(); }); document.getElementById('btn-rotate-left').addEventListener('click', () => rotateCurrent(-90)); document.getElementById('btn-rotate-right').addEventListener('click', () => rotateCurrent(90)); } 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 || ''); 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); })();