/* * 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 currentPageLayout = 'single'; let currentPageScale = 1.0; let currentPageAlign = 'fit'; 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() { // Leer-Zustand nur wenn GAR keine Seite existiert const wrapEmpty = document.querySelector('.bericht-canvas-wrap'); const hasAnyThumb = !!document.querySelector('#bericht-page-list .page-thumb'); if (wrapEmpty && !hasAnyThumb) wrapEmpty.classList.add('empty'); // 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; // Empty-Status beenden — Canvas bekommt jetzt echten Inhalt const wrapL = document.querySelector('.bericht-canvas-wrap'); if (wrapL) wrapL.classList.remove('empty'); // wird gleich aus loadPageMeta überschrieben falls gespeichert fabricCanvas.clear(); document.getElementById('page-note').value = ''; await loadPageMeta(); // setzt currentPageRotation + ggf. loadedFabricJson if (loadedFabricJson) { // Existierender State wird geladen — enthält bereits Bild, Shapes, Text etc. try { const parsed = (typeof loadedFabricJson === 'string') ? JSON.parse(loadedFabricJson) : loadedFabricJson; // Canvas-Dimensionen aus dem geladenen JSON übernehmen und skalieren const target = getTargetCanvasWidth(); const pageAspect = 1 / 1.414; const isLandscape = currentPageRotation === 90 || currentPageRotation === 270; pdfCanvas.width = target; pdfCanvas.height = isLandscape ? Math.round(target * pageAspect) : Math.round(target / pageAspect); const ctx = pdfCanvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, pdfCanvas.width, pdfCanvas.height); resizeFabricToCanvas(); await new Promise(res => { fabricCanvas.loadFromJSON(parsed, () => { fabricCanvas.renderAll(); res(); }); }); } catch (e) { console.warn('Fabric-JSON restore fehlgeschlagen, fallback auf Neu-Render:', e); await rerenderCurrent(); } } else { // Erster Besuch der Seite: Quellbild frisch als Fabric-Image laden await rerenderCurrent(); } } /** * 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) { // Phase 6: Das Quell-Bild kommt als ZIEHBARES Fabric-Image-Objekt in // den Canvas, nicht mehr als festes Hintergrund-Bild. User kann es // verschieben, skalieren, rotieren wie jedes andere Fabric-Objekt. const target = getTargetCanvasWidth(); // A4-Hochformat als Canvas-Grundfläche const pageAspect = 1 / 1.414; // Breite:Höhe const isLandscape = currentPageRotation === 90 || currentPageRotation === 270; const canvasW = target; const canvasH = isLandscape ? Math.round(target * pageAspect) : Math.round(target / pageAspect); // pdfCanvas wird nur noch weiße Fläche (für Fabric-Overlay-Positionierung) pdfCanvas.width = canvasW; pdfCanvas.height = canvasH; const ctx = pdfCanvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvasW, canvasH); resizeFabricToCanvas(); // Bild als Fabric-Image laden const blob = new Blob([arrayBuffer], { type: mime }); const url = URL.createObjectURL(blob); return new Promise((res) => { fabric.Image.fromURL(url, (fabricImg) => { // Wenn schon ein Bild-Objekt im Canvas ist (nach re-render), // altes entfernen bevor neues rein kommt const existing = fabricCanvas.getObjects().find(o => o.bgImage === true); if (existing) fabricCanvas.remove(existing); // Markiere dieses Objekt als "Hintergrundbild" (bleibt aber editierbar) fabricImg.bgImage = true; // Auf Canvas-Größe skalieren (contain) const imgRatio = Math.min(canvasW / fabricImg.width, canvasH / fabricImg.height); fabricImg.scale(imgRatio); fabricImg.set({ left: (canvasW - fabricImg.width * imgRatio) / 2, top: (canvasH - fabricImg.height * imgRatio) / 2, angle: currentPageRotation, selectable: true, hasControls: true, hasBorders: true, lockRotation: false, }); // Ziehbares Hintergrundbild ganz nach hinten fabricCanvas.add(fabricImg); fabricImg.sendToBack(); fabricCanvas.requestRenderAll(); URL.revokeObjectURL(url); res(); }, { crossOrigin: 'anonymous' }); }); } 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'; }); } let loadedFabricJson = null; async function loadPageMeta() { loadedFabricJson = null; 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) loadedFabricJson = data.fabric_json; if (data.note) document.getElementById('page-note').value = data.note; if (typeof data.rotation !== 'undefined' && data.rotation !== null) { currentPageRotation = parseInt(data.rotation, 10) || 0; } if (data.layout) currentPageLayout = data.layout; if (data.image_scale) currentPageScale = parseFloat(data.image_scale); if (data.image_align) currentPageAlign = data.image_align; // Toolbar-Selects synchronisieren const lEl = document.getElementById('page-layout'); const sEl = document.getElementById('page-imgscale'); const aEl = document.getElementById('page-imgalign'); if (lEl) lEl.value = currentPageLayout; if (sEl) sEl.value = currentPageScale.toString(); if (aEl) aEl.value = currentPageAlign; document.querySelectorAll('.single-only').forEach(el => { el.style.display = (currentPageLayout === 'single') ? '' : 'none'; }); } 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)); // Layout / Bildgröße / Align — pro Seite const layoutEl = document.getElementById('page-layout'); const scaleEl = document.getElementById('page-imgscale'); const alignEl = document.getElementById('page-imgalign'); async function savePageOptions() { if (!currentPageId) return; const fd = new FormData(); fd.append('token', cfg.token); fd.append('pageid', currentPageId); fd.append('layout', layoutEl.value); fd.append('image_scale', scaleEl.value); fd.append('image_align', alignEl.value); await fetch(cfg.urls.save_page_options, { method: 'POST', body: fd }); } if (layoutEl) layoutEl.addEventListener('change', async () => { currentPageLayout = layoutEl.value; document.querySelectorAll('.single-only').forEach(el => { el.style.display = (currentPageLayout === 'single') ? '' : 'none'; }); await savePageOptions(); }); if (scaleEl) scaleEl.addEventListener('change', async () => { currentPageScale = parseFloat(scaleEl.value); await savePageOptions(); }); if (alignEl) alignEl.addEventListener('change', async () => { currentPageAlign = alignEl.value; await savePageOptions(); }); // 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); // Text-Hintergrund const bgEl = document.getElementById('tool-bgcolor'); const bgOff = document.getElementById('tool-bg-off'); if (bgEl) { bgEl.dataset.active = 'on'; bgEl.addEventListener('input', () => { bgEl.dataset.active = 'on'; const sel = fabricCanvas.getActiveObject(); if (sel && (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox')) { sel.set({ textBackgroundColor: bgEl.value, padding: 6 }); fabricCanvas.requestRenderAll(); } }); } if (bgOff) { bgOff.addEventListener('click', () => { if (bgEl) bgEl.dataset.active = 'off'; const sel = fabricCanvas.getActiveObject(); if (sel && (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox')) { sel.set({ textBackgroundColor: '', padding: 0 }); fabricCanvas.requestRenderAll(); } }); } // 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 bgEl = document.getElementById('tool-bgcolor'); const bgActive = bgEl && bgEl.dataset.active !== 'off'; const bgColor = bgActive ? bgEl.value : ''; 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, textBackgroundColor: bgColor || '', padding: bgColor ? 6 : 0, }); 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); // Fabric-JSON MIT Hintergrundbild (bgImage-Objekte werden serialisiert) fd.append('fabric_json', JSON.stringify(fabricCanvas.toJSON(['bgImage']))); fd.append('note', document.getElementById('page-note').value || ''); fd.append('rotation', currentPageRotation); // Phase 6: Composite-PNG der gesamten Seite generieren und mitschicken try { // Selektion aufheben damit keine Controls gerendert werden const active = fabricCanvas.getActiveObject(); if (active) fabricCanvas.discardActiveObject(); // Weißer Hintergrund für den Composite, damit transparente Bereiche nicht schwarz werden const origBg = fabricCanvas.backgroundColor; fabricCanvas.backgroundColor = '#ffffff'; fabricCanvas.renderAll(); const dataUrl = fabricCanvas.toDataURL({ format: 'png', quality: 0.92, multiplier: 2, // 2x für bessere PDF-Qualität }); // Wiederherstellen fabricCanvas.backgroundColor = origBg; if (active) fabricCanvas.setActiveObject(active); fabricCanvas.renderAll(); const blob = await (await fetch(dataUrl)).blob(); fd.append('composite', blob, 'composite.png'); } catch (e) { console.warn('Composite-PNG konnte nicht erzeugt werden:', e); } 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(); }); // Als Vorlage speichern const tplBtn = document.getElementById('btn-save-as-template'); if (tplBtn) { tplBtn.addEventListener('click', async () => { const label = prompt('Label für die Vorlage:\n(z. B. "PV-Anlage Standard" oder "Wallbox 11kW")'); if (!label) return; await savePageAnnotations(false); const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); fd.append('label', label); const r = await fetch(cfg.urls.save_as_template, { method: 'POST', body: fd }); const data = await r.json(); if (data.success) toast('✓ Vorlage "' + label + '" gespeichert'); else alert('Fehler: ' + (data.error || '')); }); } 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(); }); } // Grid-Buttons: ausgewählte Bilder als eine Multi-Image-Seite hinzufügen document.querySelectorAll('.btn-add-grid').forEach(b => { b.addEventListener('click', async () => { const layout = b.dataset.layout; const checks = document.querySelectorAll('.att-check:checked'); if (!checks.length) { alert('Bitte zuerst Bilder ankreuzen'); return; } const slotCount = { grid_2: 2, grid_2v: 2, grid_4: 4, grid_6: 6 }[layout] || 4; const relpaths = Array.from(checks) .filter(c => c.dataset.mime.startsWith('image')) .slice(0, slotCount) .map(c => c.dataset.relpath); if (!relpaths.length) { alert('Bitte mindestens ein Bild ankreuzen (PDFs sind in Grids nicht unterstützt)'); return; } const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); fd.append('layout', layout); fd.append('relpaths', JSON.stringify(relpaths)); const r = await fetch(cfg.urls.create_grid_page, { method: 'POST', body: fd }); const data = await r.json().catch(() => ({})); if (data.success) location.reload(); else alert('Fehler: ' + (data.error || 'unbekannt')); }); }); // 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) { 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 || '')); }); } // QR-Modal für Mobile-Upload const qrBtn = document.getElementById('btn-show-qr'); if (qrBtn) qrBtn.addEventListener('click', openQrModal); const qrClose = document.getElementById('bericht-qr-close'); if (qrClose) qrClose.addEventListener('click', closeQrModal); document.querySelector('#bericht-qr-modal .bericht-modal-backdrop') ?.addEventListener('click', closeQrModal); } let qrPollInterval = null; let qrLastPageCount = null; async function openQrModal() { const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); const r = await fetch(cfg.urls.create_upload_token, { method: 'POST', body: fd }); const data = await r.json(); if (!data.success) { alert('Token-Erstellung fehlgeschlagen: ' + (data.error || '')); return; } const url = data.url; const qrContainer = document.getElementById('qr-code-container'); qrContainer.innerHTML = ''; if (typeof QRCode !== 'undefined') { new QRCode(qrContainer, { text: url, width: 280, height: 280, colorDark: '#000', colorLight: '#fff', correctLevel: QRCode.CorrectLevel.M, }); } else { qrContainer.textContent = url; } document.getElementById('qr-validity').textContent = data.expires_in_min; const linkEl = document.getElementById('qr-url-link'); linkEl.href = url; linkEl.textContent = url.length > 60 ? url.substring(0, 57) + '...' : url; document.getElementById('bericht-qr-modal').style.display = 'block'; // Polling alle 5 Sek nach neuen Pages qrLastPageCount = document.querySelectorAll('.page-thumb').length; if (qrPollInterval) clearInterval(qrPollInterval); qrPollInterval = setInterval(async () => { try { const r = await fetch(cfg.urls.list_pages + '?berichtid=' + cfg.berichtid); const d = await r.json(); if (d.success && d.count !== qrLastPageCount) { document.querySelector('.qr-status').textContent = '✓ ' + (d.count - qrLastPageCount) + ' neue(s) Foto(s) hochgeladen — Seite wird neu geladen…'; setTimeout(() => location.reload(), 1500); clearInterval(qrPollInterval); } } catch (e) {} }, 5000); } function closeQrModal() { const m = document.getElementById('bericht-qr-modal'); if (m) m.style.display = 'none'; if (qrPollInterval) { clearInterval(qrPollInterval); qrPollInterval = null; } } 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'); }); // Unterschriften-Verifikation document.querySelectorAll('.thumb-verify').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const pageid = btn.dataset.pageid; btn.textContent = '⏳'; try { const fd = new FormData(); fd.append('token', cfg.token); fd.append('pageid', pageid); const r = await fetch(cfg.urls.verify_signature, { method: 'POST', body: fd }); const data = await r.json(); btn.textContent = '🔍'; if (!data.success) { alert('Fehler: ' + (data.error || 'unbekannt')); return; } showSignatureVerifyResult(data); } catch (err) { btn.textContent = '🔍'; alert('Netzwerkfehler: ' + err.message); } }); }); } /** * Zeigt das Ergebnis der Unterschriften-Verifikation in einem Modal. */ function showSignatureVerifyResult(data) { const verified = data.verified; const m = data.meta || {}; const icon = verified ? '✅' : '⚠️'; const title = verified ? 'Unterschrift verifiziert' : 'Unterschrift NICHT verifiziert'; const bg = verified ? '#5cb85c' : '#d9534f'; const html = `

${icon} ${escapeHtml(title)}

${!verified ? `

${escapeHtml(data.reason || '')}

` : ''} ${m.gps_lat ? `` : ''}
Unterzeichner:${escapeHtml(m.signer_name || '—')}
Kunde:${escapeHtml(m.kunde || '')}
Parent:${escapeHtml(m.parent_ref || '')}
Signiert am:${escapeHtml(m.signed_at || '')}
Erfasst durch:${escapeHtml(m.user_login || '')}
GPS:${m.gps_lat}, ${m.gps_lon}
IP:${escapeHtml(m.remote_ip || '')}
Gespeicherter Hash:${escapeHtml(data.stored_hash || '')}
Aktueller Hash:${escapeHtml(data.current_hash || '')}
Seiten bei Signatur:${data.expected_page_count}
Seiten jetzt vor Signatur:${data.current_page_count}
`; const wrapper = document.createElement('div'); wrapper.innerHTML = html; document.body.appendChild(wrapper.firstElementChild); document.getElementById('sig-verify-close').onclick = () => { document.getElementById('sig-verify-modal').remove(); }; } function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } /** * 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); })();