/* * 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 currentZoom = 1.0; // 1.0 = 100% (Container-Fit), 0.5..3.0 let fabricCanvas = null; const pdfCanvas = document.getElementById('pdf-canvas'); let currentTool = 'select'; /* ---------- Settings-Persistenz (localStorage) ---------- */ const SETTINGS_KEY = 'bericht.editor.settings.v1'; function loadSettings() { try { const raw = localStorage.getItem(SETTINGS_KEY); if (!raw) return {}; return JSON.parse(raw) || {}; } catch (e) { return {}; } } function saveSettings(patch) { try { const cur = loadSettings(); const merged = Object.assign({}, cur, patch); localStorage.setItem(SETTINGS_KEY, JSON.stringify(merged)); } catch (e) { /* localStorage full/blocked */ } } /* ---------- Init ---------- */ function init() { // Gespeicherte Einstellungen anwenden — VOR Fabric-Init const s = loadSettings(); const colorEl = document.getElementById('tool-color'); const strokeEl = document.getElementById('tool-stroke'); const ffEl = document.getElementById('tool-fontfamily'); const fsEl = document.getElementById('tool-fontsize'); const boldEl = document.getElementById('tool-bold'); const italicEl = document.getElementById('tool-italic'); if (s.color) colorEl.value = s.color; if (s.stroke) strokeEl.value = s.stroke; if (s.fontFamily) ffEl.value = s.fontFamily; if (s.fontSize) fsEl.value = s.fontSize; if (typeof s.bold !== 'undefined') boldEl.checked = !!s.bold; if (typeof s.italic !== 'undefined') italicEl.checked = !!s.italic; if (s.zoom) currentZoom = parseFloat(s.zoom) || 1.0; document.getElementById('zoom-label').textContent = Math.round(currentZoom * 100) + '%'; // Fabric initialisieren (wird beim ersten Seitenrendern dimensioniert) fabricCanvas = new fabric.Canvas('fabric-canvas', { isDrawingMode: false, selection: true, }); fabricCanvas.freeDrawingBrush.color = colorEl.value; fabricCanvas.freeDrawingBrush.width = parseInt(strokeEl.value, 10); // Listener: speichern bei jeder Änderung colorEl.addEventListener('change', () => saveSettings({ color: colorEl.value })); strokeEl.addEventListener('change', () => saveSettings({ stroke: parseInt(strokeEl.value, 10) })); ffEl.addEventListener('change', () => saveSettings({ fontFamily: ffEl.value })); fsEl.addEventListener('change', () => saveSettings({ fontSize: parseInt(fsEl.value, 10) })); boldEl.addEventListener('change', () => saveSettings({ bold: boldEl.checked })); italicEl.addEventListener('change', () => saveSettings({ italic: italicEl.checked })); // Erste Seite laden (wenn vorhanden) const firstThumb = document.querySelector('#bericht-page-list .page-thumb'); if (firstThumb) loadPage(firstThumb); // Alle Thumbnails parallel rendern renderAllThumbs(); bindThumbs(); bindToolbar(); bindAttachments(); bindExtraUpload(); bindActions(); bindSortable(); // Re-Render bei Größenänderung des Container (Console öffnen, Window-Resize), // debounced damit es nicht spamt. Nutzt ResizeObserver auf canvas-wrap. const wrap = document.querySelector('.bericht-canvas-wrap'); if (wrap && typeof ResizeObserver !== 'undefined') { let to = null; let lastW = wrap.clientWidth; const ro = new ResizeObserver(() => { if (Math.abs(wrap.clientWidth - lastW) < 20) return; lastW = wrap.clientWidth; clearTimeout(to); to = setTimeout(() => { rerenderCurrent(); }, 250); }); ro.observe(wrap); } } /* ---------- 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; const base = Math.max(300, Math.min(1200, Math.floor(avail))); return Math.round(base * currentZoom); } 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() { fabricCanvas.setWidth(pdfCanvas.width); fabricCanvas.setHeight(pdfCanvas.height); requestAnimationFrame(() => { // Fabric wickelt das Canvas in einen .canvas-container ein — // DIESEN müssen wir absolut über dem PDF-Canvas positionieren, // nicht das innere #fabric-canvas direkt. const fcEl = document.getElementById('fabric-canvas'); const container = fcEl.parentElement && fcEl.parentElement.classList.contains('canvas-container') ? fcEl.parentElement : fcEl; const rect = pdfCanvas.getBoundingClientRect(); const wrap = pdfCanvas.parentElement; const wrapRect = wrap.getBoundingClientRect(); container.style.position = 'absolute'; container.style.left = (rect.left - wrapRect.left + wrap.scrollLeft) + 'px'; container.style.top = (rect.top - wrapRect.top + wrap.scrollTop) + 'px'; container.style.width = pdfCanvas.clientWidth + 'px'; container.style.height = pdfCanvas.clientHeight + 'px'; container.style.zIndex = '10'; container.style.pointerEvents = 'auto'; }); } 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 document.getElementById('btn-rotate-left').addEventListener('click', () => rotatePage(-90)); document.getElementById('btn-rotate-right').addEventListener('click', () => rotatePage(90)); // Zoom document.getElementById('btn-zoom-in').addEventListener('click', () => setZoom(currentZoom + 0.25)); document.getElementById('btn-zoom-out').addEventListener('click', () => setZoom(currentZoom - 0.25)); document.getElementById('btn-zoom-reset').addEventListener('click', () => setZoom(1.0)); // Schrift-Optionen für Text-Tool / selektierte Texte const fontFamily = document.getElementById('tool-fontfamily'); const fontSize = document.getElementById('tool-fontsize'); const boldChk = document.getElementById('tool-bold'); const italicChk = document.getElementById('tool-italic'); function applyTextProps() { const sel = fabricCanvas.getActiveObject(); if (sel && (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox')) { sel.set({ fontFamily: fontFamily.value, fontSize: parseInt(fontSize.value, 10), fontWeight: boldChk.checked ? 'bold' : 'normal', fontStyle: italicChk.checked ? 'italic' : 'normal', }); fabricCanvas.requestRenderAll(); } } fontFamily.addEventListener('change', applyTextProps); fontSize.addEventListener('input', applyTextProps); boldChk.addEventListener('change', applyTextProps); italicChk.addEventListener('change', applyTextProps); // Bei Selektion eines Text-Objekts die Toolbar-Werte synchronisieren fabricCanvas.on('selection:created', syncTextToolbar); fabricCanvas.on('selection:updated', syncTextToolbar); function syncTextToolbar() { const sel = fabricCanvas.getActiveObject(); if (!sel) return; if (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox') { if (sel.fontFamily) fontFamily.value = sel.fontFamily; if (sel.fontSize) fontSize.value = sel.fontSize; boldChk.checked = (sel.fontWeight === 'bold'); italicChk.checked = (sel.fontStyle === 'italic'); } } } async function setZoom(z) { currentZoom = Math.max(0.25, Math.min(3.0, Math.round(z * 100) / 100)); document.getElementById('zoom-label').textContent = Math.round(currentZoom * 100) + '%'; saveSettings({ zoom: currentZoom }); await rerenderCurrent(); } 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); } /* ---------- Shape-Drawing per Drag ---------- */ let drawState = null; // { shape, startX, startY } function applyTool() { fabricCanvas.isDrawingMode = (currentTool === 'draw'); fabricCanvas.selection = (currentTool === 'select'); // Cursor-Hint fabricCanvas.defaultCursor = (currentTool === 'select') ? 'default' : 'crosshair'; fabricCanvas.off('mouse:down', shapeDown); fabricCanvas.off('mouse:move', shapeMove); fabricCanvas.off('mouse:up', shapeUp); if (['rect', 'circle', 'arrow'].includes(currentTool)) { fabricCanvas.on('mouse:down', shapeDown); fabricCanvas.on('mouse:move', shapeMove); fabricCanvas.on('mouse:up', shapeUp); } else if (currentTool === 'text') { fabricCanvas.on('mouse:down', shapeDown); } } function shapeDown(opt) { // Wenn wir ein vorhandenes Objekt anklicken: nicht zeichnen, selektieren if (opt.target) return; const p = fabricCanvas.getPointer(opt.e); const color = document.getElementById('tool-color').value; const sw = parseInt(document.getElementById('tool-stroke').value, 10); if (currentTool === 'rect') { const r = new fabric.Rect({ left: p.x, top: p.y, width: 1, height: 1, fill: 'transparent', stroke: color, strokeWidth: sw, originX: 'left', originY: 'top' }); fabricCanvas.add(r); drawState = { shape: r, startX: p.x, startY: p.y }; } else if (currentTool === 'circle') { const e = new fabric.Ellipse({ left: p.x, top: p.y, rx: 1, ry: 1, fill: 'transparent', stroke: color, strokeWidth: sw, originX: 'left', originY: 'top' }); fabricCanvas.add(e); drawState = { shape: e, startX: p.x, startY: p.y }; } else if (currentTool === 'arrow') { const a = makeArrow(p.x, p.y, p.x + 1, p.y + 1, color, sw); fabricCanvas.add(a); drawState = { shape: a, startX: p.x, startY: p.y, isArrow: true, color, sw }; } else if (currentTool === 'text') { const ff = document.getElementById('tool-fontfamily').value || 'Helvetica'; const fs = parseInt(document.getElementById('tool-fontsize').value, 10) || 24; const bold = document.getElementById('tool-bold').checked; const ital = document.getElementById('tool-italic').checked; const t = new fabric.IText('Text…', { left: p.x, top: p.y, fontFamily: ff, fontSize: fs, fontWeight: bold ? 'bold' : 'normal', fontStyle: ital ? 'italic' : 'normal', fill: color }); fabricCanvas.add(t); fabricCanvas.setActiveObject(t); const sb = document.querySelector('.tool-btn[data-tool="select"]'); if (sb) sb.click(); } } function shapeMove(opt) { if (!drawState) return; const p = fabricCanvas.getPointer(opt.e); const s = drawState.shape; if (drawState.isArrow) { // Alten Pfeil entfernen, neuen mit aktualisierten Endpunkten zeichnen fabricCanvas.remove(s); const newArrow = makeArrow(drawState.startX, drawState.startY, p.x, p.y, drawState.color, drawState.sw); fabricCanvas.add(newArrow); drawState.shape = newArrow; } else if (s.type === 'rect') { const w = p.x - drawState.startX; const h = p.y - drawState.startY; s.set({ left: w < 0 ? p.x : drawState.startX, top: h < 0 ? p.y : drawState.startY, width: Math.abs(w), height: Math.abs(h), }); } else if (s.type === 'ellipse') { const w = p.x - drawState.startX; const h = p.y - drawState.startY; s.set({ left: w < 0 ? p.x : drawState.startX, top: h < 0 ? p.y : drawState.startY, rx: Math.abs(w) / 2, ry: Math.abs(h) / 2, }); // Ellipse braucht ihre Größe anhand rx/ry s.set({ width: Math.abs(w), height: Math.abs(h) }); } s.setCoords(); fabricCanvas.requestRenderAll(); } function shapeUp() { if (drawState && drawState.shape) { const s = drawState.shape; // Sicherstellen dass das Objekt selektierbar/drehbar ist s.set({ hasControls: true, hasBorders: true, lockRotation: false }); fabricCanvas.setActiveObject(s); } drawState = null; // Nach dem Zeichnen automatisch auf Select wechseln const sb = document.querySelector('.tool-btn[data-tool="select"]'); if (sb) sb.click(); } /** * Baut einen Pfeil als Fabric-Group: Linie + Dreieck als Spitze. * Gruppe ist drehbar, skalierbar, verschiebbar. */ function makeArrow(x1, y1, x2, y2, color, sw) { const dx = x2 - x1, dy = y2 - y1; const len = Math.sqrt(dx * dx + dy * dy) || 1; const angle = Math.atan2(dy, dx); const headLen = Math.max(12, sw * 4); // Linie als Path damit sie in die Group passt — relative Koordinaten const line = new fabric.Line([0, 0, len, 0], { stroke: color, strokeWidth: sw, originX: 'left', originY: 'center', }); const head = new fabric.Triangle({ left: len, top: 0, width: headLen, height: headLen, fill: color, originX: 'center', originY: 'center', angle: 90, }); const grp = new fabric.Group([line, head], { left: x1, top: y1, originX: 'left', originY: 'center', angle: angle * 180 / Math.PI, hasControls: true, hasBorders: true, lockRotation: false, }); return grp; } /* ---------- 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'); } /* ---------- Meta-Felder Auto-Save ---------- */ async function saveMeta() { const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); const titelEl = document.querySelector('input[name="titel"]'); const tplEl = document.querySelector('select[name="template_odt"]'); const fmtEl = document.getElementById('meta-format'); const oriEl = document.getElementById('meta-orientation'); if (titelEl) fd.append('titel', titelEl.value); if (tplEl) fd.append('template_odt', tplEl.value); if (fmtEl) fd.append('page_format', fmtEl.value); if (oriEl) fd.append('page_orientation', oriEl.value); await fetch(cfg.urls.save_meta, { method: 'POST', body: fd }); } function bindMetaAutoSave() { ['input[name="titel"]', 'select[name="template_odt"]', '#meta-format', '#meta-orientation'].forEach(sel => { const el = document.querySelector(sel); if (!el) return; el.addEventListener('change', saveMeta); }); } function bindActions() { bindMetaAutoSave(); document.getElementById('btn-save-draft').addEventListener('click', async () => { await saveMeta(); await savePageAnnotations(true); }); // Vorschau-Modal const previewBtn = document.getElementById('btn-preview'); if (previewBtn) { previewBtn.addEventListener('click', async () => { await savePageAnnotations(false); const url = cfg.urls.preview_pdf + '?berichtid=' + cfg.berichtid + '&t=' + Date.now(); document.getElementById('bericht-preview-iframe').src = url; document.getElementById('bericht-preview-modal').style.display = 'block'; }); } const modalClose = document.getElementById('bericht-modal-close'); if (modalClose) modalClose.addEventListener('click', closePreviewModal); document.querySelector('#bericht-preview-modal .bericht-modal-backdrop') ?.addEventListener('click', closePreviewModal); document.addEventListener('keydown', e => { if (e.key === 'Escape') closePreviewModal(); }); 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) { 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(); }); } // Lösch-Buttons in der Anhänge-Liste document.querySelectorAll('.att-delete').forEach(btn => { btn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const rel = btn.dataset.relpath; const ref = btn.dataset.sourceRef || ''; const name = btn.parentElement.querySelector('.att-name')?.textContent || rel; if (!confirm('Datei "' + name + '" aus ' + (ref ? ref : 'dem Anhang') + ' wirklich löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden.')) return; const fd = new FormData(); fd.append('token', cfg.token); fd.append('relpath', rel); const r = await fetch(cfg.urls.delete_attachment, { method: 'POST', body: fd }); const data = await r.json().catch(() => ({})); if (data.success) { btn.parentElement.remove(); toast('Datei gelöscht'); } else { alert('Löschen fehlgeschlagen: ' + (data.error || 'unbekannt')); } }); }); } 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(); }); }); // Hell/Dunkel-Toggle const tg = document.getElementById('btn-toggle-thumb-bg'); if (tg) tg.addEventListener('click', () => { const list = document.getElementById('bericht-page-list'); list.classList.toggle('paper-light'); list.classList.toggle('paper-dark'); }); } /** * Rendert alle Thumbnails in der rechten Seitenleiste. * Holt jedes Bild über page_image.php und zeichnet es klein in das Thumb-Canvas. * PDFs werden mit PDF.js gerendert. */ async function renderAllThumbs() { const thumbs = document.querySelectorAll('.page-thumb'); for (const t of thumbs) { const pageid = t.dataset.pageid; const canvas = t.querySelector('.thumb-canvas'); if (!canvas || !pageid) continue; try { const r = await fetch(cfg.urls.page_image + '?pageid=' + pageid); const ct = r.headers.get('Content-Type') || ''; const buf = await r.arrayBuffer(); if (ct.includes('pdf')) { await renderThumbPdf(canvas, buf); } else if (ct.includes('image')) { await renderThumbImage(canvas, buf, ct); } } catch (e) { /* skip */ } } } async function renderThumbImage(canvas, buf, mime) { return new Promise(res => { const blob = new Blob([buf], { type: mime }); const url = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { const maxSide = 200; const ratio = Math.min(maxSide / img.width, maxSide / img.height); canvas.width = Math.round(img.width * ratio); canvas.height = Math.round(img.height * ratio); const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); res(); }; img.onerror = () => { URL.revokeObjectURL(url); res(); }; img.src = url; }); } async function renderThumbPdf(canvas, buf) { if (!window.pdfjsLib) return; try { const doc = await pdfjsLib.getDocument({ data: buf.slice(0) }).promise; const page = await doc.getPage(1); const base = page.getViewport({ scale: 1 }); const scale = 200 / base.width; const vp = page.getViewport({ scale: scale }); canvas.width = vp.width; canvas.height = vp.height; await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise; } catch (e) { /* skip */ } } 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 }); } }); } function closePreviewModal() { const m = document.getElementById('bericht-preview-modal'); if (m) m.style.display = 'none'; const ifr = document.getElementById('bericht-preview-iframe'); if (ifr) ifr.src = 'about:blank'; } /* ---------- 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); })();