/* * 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; } /** * Liest den Notiz-Text aus — funktioniert mit CKEditor (DolEditor) UND plain textarea */ function getNoteValue() { if (window.CKEDITOR && window.CKEDITOR.instances && window.CKEDITOR.instances['page-note']) { return window.CKEDITOR.instances['page-note'].getData() || ''; } const el = document.getElementById('page-note'); return el ? el.value : ''; } /** * Setzt den Notiz-Text. */ function setNoteValue(html) { if (window.CKEDITOR && window.CKEDITOR.instances && window.CKEDITOR.instances['page-note']) { window.CKEDITOR.instances['page-note'].setData(html || ''); return; } const el = document.getElementById('page-note'); if (el) el.value = html || ''; } // PDF.js worker (lokal) if (window.pdfjsLib) { // Pfad kommt aus PHP (dol_buildpath) — hartkodiert fehlte das /custom-Präfix, // dadurch lief PDF.js im "fake worker"-Modus und blockierte den Hauptthread. pdfjsLib.GlobalWorkerOptions.workerSrc = (cfg.urls && cfg.urls.pdf_worker) || '/custom/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'; /* ---------- Seitengeometrie (kommt aus PHP, siehe bericht_page_geometry) ---------- */ function pageGeometry() { const g = cfg.geometry || {}; const hasNote = !!(getNoteValue && getNoteValue().replace(/<[^>]*>/g, '').trim()); const geo = (hasNote ? g.note : g.plain) || g.plain; // Notfall-Fallback, falls die Konfiguration fehlt: A4 hoch return geo || { pageW: 210, pageH: 297, mL: 10, mR: 10, mT: 30, mB: 16, noteH: 0, contentX: 10, contentY: 30, contentW: 190, contentH: 251 }; } /** * Setzt die Blatt-Darstellung: Kopf-, Inhalts- und Fußbereich im * Verhältnis der echten Seitenmaße. Der Canvas ist der Inhaltsbereich. */ function applySheetGeometry(canvasW, canvasH) { const sheet = document.getElementById('bericht-sheet'); if (!sheet) return; const g = pageGeometry(); // Der Canvas entspricht contentW x contentH — daraus ergibt sich der // Maßstab, mit dem alle anderen Zonen gezeichnet werden. const pxPerMm = canvasW / g.contentW; sheet.style.width = Math.round(g.pageW * pxPerMm) + 'px'; sheet.style.paddingLeft = Math.round(g.mL * pxPerMm) + 'px'; sheet.style.paddingRight = Math.round(g.mR * pxPerMm) + 'px'; const head = sheet.querySelector('.sheet-header'); if (head) head.style.height = Math.round(g.mT * pxPerMm) + 'px'; const foot = sheet.querySelector('.sheet-footer'); if (foot) foot.style.height = Math.round(g.mB * pxPerMm) + 'px'; setSheetZonesVisible(true); const noteZone = document.getElementById('sheet-note-zone'); if (noteZone) { if (g.noteH > 0) { noteZone.style.display = ''; noteZone.style.height = Math.round(g.noteH * pxPerMm) + 'px'; noteZone.style.marginTop = Math.round(4 * pxPerMm) + 'px'; } else { noteZone.style.display = 'none'; } } } const MARGIN_KEY = 'bericht.editor.margins.v1'; let marginsOn = true; /** Kopf-/Fuß-/Notizzone ein- oder ausblenden (bei Bild-Seiten). */ function setSheetZonesVisible(on) { const sheet = document.getElementById('bericht-sheet'); if (!sheet) return; sheet.classList.toggle('zones-hidden', !on || !marginsOn); } function bindMarginToggle() { const btn = document.getElementById('btn-toggle-margins'); try { marginsOn = localStorage.getItem(MARGIN_KEY) !== '0'; } catch (e) { /* blockiert */ } if (btn) { btn.classList.toggle('active', marginsOn); btn.addEventListener('click', () => { marginsOn = !marginsOn; try { localStorage.setItem(MARGIN_KEY, marginsOn ? '1' : '0'); } catch (e) { /* blockiert */ } btn.classList.toggle('active', marginsOn); setSheetZonesVisible(true); }); } setSheetZonesVisible(true); } /** Seitenverhältnis des Inhaltsbereichs (Breite/Höhe) */ function contentAspect() { const g = pageGeometry(); return g.contentW / g.contentH; } /* ---------- 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 */ } } /* ---------- Anhänge-Spalte: Miniaturen, Auswahl, Lightbox ---------- */ // Auswahl-Reihenfolge (relpath-Liste) — bestimmt die Slot-Reihenfolge beim Übernehmen let attSelection = []; let attViewer = null; const ATT_VIEW_KEY = 'bericht.attachments.view.v1'; function bindAttachmentPanel() { const list = document.getElementById('bericht-att-list'); if (!list) return; // --- Ansicht (Kachel/Liste) wiederherstellen --- let view = 'grid'; try { view = localStorage.getItem(ATT_VIEW_KEY) || 'grid'; } catch (e) { /* blockiert */ } applyAttView(view); document.querySelectorAll('.att-view-btn').forEach(btn => { btn.addEventListener('click', () => { applyAttView(btn.dataset.view); try { localStorage.setItem(ATT_VIEW_KEY, btn.dataset.view); } catch (e) { /* blockiert */ } }); }); // --- Lightbox: genau eine Instanz, sonst sammeln sich Overlays im DOM --- if (typeof window.BerichtImageViewer !== 'undefined') { if (!attViewer) attViewer = new window.BerichtImageViewer(); } else { console.warn('BerichtImageViewer nicht geladen'); } const clearBtn = document.getElementById('att-clear-selection'); if (clearBtn) { clearBtn.addEventListener('click', () => { document.querySelectorAll('#bericht-att-list .att-check:checked') .forEach(cb => { cb.checked = false; }); updateAttSelection(); }); } bindAttSortFilter(); bindTileSize(); bindAttResize(); bindDropTargets(); bindPageBulk(); bindAttachmentEvents(); } /** Events an den Kacheln — nach jedem Neuaufbau der Liste erneut nötig. */ function bindAttachmentEvents() { const list = document.getElementById('bericht-att-list'); if (!list) return; list.querySelectorAll('.att-thumb').forEach(thumb => { thumb.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (replaceMode) { // Austauschmodus hat Vorrang applyReplace(thumb.closest('.bericht-att-item')); return; } if (thumb.dataset.type === 'other') return; // nichts zum Anzeigen openAttachmentViewer(thumb); }); }); // --- Auswahl: Klick auf die Beschriftung wählt aus (grosses Klickziel) --- list.querySelectorAll('.att-meta').forEach(meta => { meta.addEventListener('click', (e) => { e.preventDefault(); const item = meta.closest('.bericht-att-item'); if (replaceMode) { applyReplace(item); return; } const cb = item && item.querySelector('.att-check'); if (!cb) return; cb.checked = !cb.checked; updateAttSelection(); }); }); list.querySelectorAll('.att-check').forEach(cb => { cb.addEventListener('change', () => updateAttSelection()); }); // --- "alle" pro Herkunfts-Gruppe --- list.querySelectorAll('.att-group-toggle').forEach(btn => { btn.addEventListener('click', () => { const group = btn.dataset.group; const boxes = Array.from(list.querySelectorAll('.bericht-att-item[data-group="' + cssEscape(group) + '"] .att-check')); const allChecked = boxes.length > 0 && boxes.every(b => b.checked); boxes.forEach(b => { b.checked = !allChecked; }); updateAttSelection(); }); }); // --- Fehlende Bild-Thumbnails: auf Dateisymbol zurückfallen --- list.querySelectorAll('.att-thumb-img').forEach(img => { img.addEventListener('error', () => { const box = img.parentElement; img.remove(); const fb = document.createElement('span'); fb.className = 'att-thumb-fallback'; fb.textContent = '🖼'; box.insertBefore(fb, box.firstChild); }); }); // --- PDF-Miniaturen erst rendern, wenn sie ins Sichtfeld kommen --- lazyRenderPdfThumbs(list); bindAttachmentDragSource(); // Lösch-Buttons in der Anhänge-Liste document.querySelectorAll('#bericht-att-list .att-delete').forEach(btn => { btn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const rel = btn.dataset.relpath; const ref = btn.dataset.sourceRef || ''; const item = btn.closest('.bericht-att-item'); const name = item?.querySelector('.att-name')?.textContent || rel; const usedHint = item?.classList.contains('is-used') ? '\n\nAchtung: Diese Datei wird in diesem Bericht bereits verwendet.' : ''; const ok = await dolConfirm( 'Die Datei "' + name + '" wird endgültig aus den Dokumenten ' + (ref ? 'von ' + ref : 'des Belegs') + ' gelöscht — nicht nur aus dieser Liste.' + '\nSie ist danach auch unter "Verknüpfte Dokumente" weg und lässt sich nicht wiederherstellen.' + usedHint, 'Datei aus dem Beleg löschen?'); if (!ok) 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) { if (item) item.remove(); updateAttSelection(); toast('Datei gelöscht'); } else { dolAlert('Löschen fehlgeschlagen: ' + (data.error || 'unbekannt')); } }); }); applyAttSortFilter(); updateAttSelection(); } const ATT_SORT_KEY = 'bericht.attachments.sort.v1'; const ATT_FILTER_KEY = 'bericht.attachments.filter.v1'; /** * Sortiert und filtert die Kacheln — innerhalb jeder Herkunfts-Gruppe, * damit die Zuordnung zum Beleg erhalten bleibt. */ function applyAttSortFilter() { const list = document.getElementById('bericht-att-list'); if (!list) return; const sort = document.getElementById('att-sort')?.value || 'taken'; const filter = document.getElementById('att-filter')?.value || 'all'; const num = (el, key) => parseInt(el.dataset[key], 10) || 0; const nm = (el) => el.dataset.name || ''; const cmp = { taken: (a, b) => num(a, 'taken') - num(b, 'taken') || nm(a).localeCompare(nm(b)), taken_desc: (a, b) => num(b, 'taken') - num(a, 'taken') || nm(a).localeCompare(nm(b)), name: (a, b) => nm(a).localeCompare(nm(b)), size_desc: (a, b) => num(b, 'size') - num(a, 'size'), }[sort]; list.querySelectorAll('.bericht-att-group').forEach(group => { const box = group.querySelector('.bericht-att-tiles'); if (!box) return; const items = Array.from(box.querySelectorAll('.bericht-att-item')); if (cmp) items.slice().sort(cmp).forEach(it => box.appendChild(it)); let visible = 0; items.forEach(it => { const kind = it.dataset.kind; let show = true; if (filter === 'image') show = (kind === 'image'); else if (filter === 'pdf') show = (kind === 'pdf'); else if (filter === 'unused') show = !it.classList.contains('is-used'); it.style.display = show ? '' : 'none'; if (show) visible++; }); // Gruppen ohne sichtbare Datei ganz ausblenden group.style.display = visible ? '' : 'none'; }); } function bindAttSortFilter() { const sortEl = document.getElementById('att-sort'); const filterEl = document.getElementById('att-filter'); try { const sv = localStorage.getItem(ATT_SORT_KEY); const fv = localStorage.getItem(ATT_FILTER_KEY); if (sv && sortEl) sortEl.value = sv; if (fv && filterEl) filterEl.value = fv; } catch (e) { /* blockiert */ } if (sortEl) sortEl.addEventListener('change', () => { try { localStorage.setItem(ATT_SORT_KEY, sortEl.value); } catch (e) { /* blockiert */ } applyAttSortFilter(); }); if (filterEl) filterEl.addEventListener('change', () => { try { localStorage.setItem(ATT_FILTER_KEY, filterEl.value); } catch (e) { /* blockiert */ } applyAttSortFilter(); }); applyAttSortFilter(); } /* ---------- Raster-Plätze direkt auf der Seite anklicken ---------- Bei Raster-Layouts liegt über der Arbeitsfläche ein Gitter aus Schaltflächen — je eine pro Platz. Ein Klick übernimmt die aktuelle Auswahl aus der Anhänge-Spalte, ein Ablegen per Drag setzt das gezogene Bild. Die Aufteilung entspricht BerichtPage::slotRectsInBox() in PHP. */ /** Platz-Rechtecke in Prozent der Arbeitsfläche. Gap 4mm bezogen auf contentW/H. */ function slotRectsPercent(layout) { const g = pageGeometry(); const gapX = (4 / g.contentW) * 100; const gapY = (4 / g.contentH) * 100; const rects = []; if (layout === 'grid_2') { const h = (100 - gapY) / 2; for (let i = 0; i < 2; i++) rects.push({ x: 0, y: i * (h + gapY), w: 100, h: h }); } else if (layout === 'grid_2v' || layout === 'before_after') { const w = (100 - gapX) / 2; for (let i = 0; i < 2; i++) rects.push({ x: i * (w + gapX), y: 0, w: w, h: 100 }); } else if (layout === 'grid_4') { const w = (100 - gapX) / 2, h = (100 - gapY) / 2; for (let r = 0; r < 2; r++) for (let c = 0; c < 2; c++) { rects.push({ x: c * (w + gapX), y: r * (h + gapY), w: w, h: h }); } } else if (layout === 'grid_6') { const w = (100 - 2 * gapX) / 3, h = (100 - gapY) / 2; for (let r = 0; r < 2; r++) for (let c = 0; c < 3; c++) { rects.push({ x: c * (w + gapX), y: r * (h + gapY), w: w, h: h }); } } return rects; } /** Baut das Platz-Gitter über der Arbeitsfläche neu auf. */ function renderSlotOverlay() { const body = document.querySelector('.bericht-sheet .sheet-body'); if (!body) return; let overlay = document.getElementById('bericht-slot-overlay'); const layout = currentPageEl?.dataset.layout || currentPageLayout || 'single'; const rects = slotRectsPercent(layout); if (!rects.length) { // Einzelbild-Seite: kein Gitter if (overlay) overlay.remove(); return; } if (!overlay) { overlay = document.createElement('div'); overlay.id = 'bericht-slot-overlay'; overlay.className = 'bericht-slot-overlay'; body.appendChild(overlay); } overlay.innerHTML = ''; updateSlotOverlayState(); // Deckungsgleich mit dem Canvas legen const canvasBox = pdfCanvas.getBoundingClientRect(); const bodyBox = body.getBoundingClientRect(); overlay.style.left = (canvasBox.left - bodyBox.left) + 'px'; overlay.style.top = (canvasBox.top - bodyBox.top) + 'px'; overlay.style.width = pdfCanvas.clientWidth + 'px'; overlay.style.height = pdfCanvas.clientHeight + 'px'; const labels = (layout === 'before_after') ? ['Vorher', 'Nachher'] : null; rects.forEach((r, i) => { const slot = document.createElement('button'); slot.type = 'button'; slot.className = 'bericht-slot'; slot.dataset.slot = String(i); slot.style.left = r.x + '%'; slot.style.top = r.y + '%'; slot.style.width = r.w + '%'; slot.style.height = r.h + '%'; slot.title = 'Platz ' + (i + 1) + (labels ? ' (' + labels[i] + ')' : '') + ' — klicken, um das ausgewählte Bild einzusetzen, oder ein Bild hierher ziehen'; slot.innerHTML = '' + (labels ? labels[i] : (i + 1)) + ''; slot.addEventListener('click', (e) => { e.preventDefault(); setSlotFromSelection(i); }); // Ablegen per Drag & Drop const over = (e) => { if (!hasDragType(e, DND_TYPE)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; slot.classList.add('drop-active'); }; slot.addEventListener('dragover', over); slot.addEventListener('dragenter', over); slot.addEventListener('dragleave', () => slot.classList.remove('drop-active')); slot.addEventListener('drop', async (e) => { const payload = readDragPayload(e); if (!payload) return; e.preventDefault(); e.stopPropagation(); slot.classList.remove('drop-active'); if (!payload[0].mime.startsWith('image')) { dolAlert('In Raster-Plätze passen nur Bilder.'); return; } await writeSlot(i, payload[0].relpath, payload[0].mime); }); overlay.appendChild(slot); }); } /** Plätze nur im Auswahlmodus anklickbar (sonst geht Zeichnen vor). */ function updateSlotOverlayState() { const overlay = document.getElementById('bericht-slot-overlay'); if (!overlay) return; overlay.classList.toggle('slots-off', currentTool !== 'select'); } /** Klick auf einen Platz: nimmt das erste ausgewählte Bild der Anhänge-Spalte. */ async function setSlotFromSelection(slot) { const selected = getSelectedAttachments().filter(sel => sel.mime.startsWith('image')); if (!selected.length) { dolAlert('Zuerst links ein Bild auswählen — oder ein Bild direkt auf den Platz ziehen.'); return; } await writeSlot(slot, selected[0].relpath, selected[0].mime); } async function writeSlot(slot, relpath, mime) { if (!currentPageId) return; const fd = new FormData(); fd.append('token', cfg.token); fd.append('pageid', currentPageId); fd.append('slot', slot); fd.append('relpath', relpath); fd.append('mime', mime || ''); const r = await fetch(cfg.urls.replace_page_source, { method: 'POST', body: fd }); const data = await r.json().catch(() => ({})); if (!data.success) { dolAlert('Platz konnte nicht gesetzt werden: ' + (data.error || 'unbekannt')); return; } const keep = currentPageId; await refreshFragments('both'); const thumb = document.querySelector('#bericht-page-list .page-thumb[data-pageid="' + keep + '"]'); if (thumb) await loadPage(thumb); toast('Platz ' + (slot + 1) + ' gesetzt'); } /* ---------- Seitenliste: mehrere Seiten markieren ---------- */ let pageSelection = []; // pageids in Klick-Reihenfolge let lastClickedPage = null; // Anker für Umschalt-Auswahl function updatePageSelection() { const thumbs = Array.from(document.querySelectorAll('#bericht-page-list .page-thumb')); const alive = thumbs.map(t => parseInt(t.dataset.pageid, 10)); pageSelection = pageSelection.filter(id => alive.includes(id)); thumbs.forEach(t => { t.classList.toggle('page-selected', pageSelection.includes(parseInt(t.dataset.pageid, 10))); }); const bar = document.getElementById('bericht-page-bulk'); const cnt = document.getElementById('page-sel-count'); if (cnt) cnt.textContent = String(pageSelection.length); if (bar) bar.style.display = pageSelection.length ? '' : 'none'; } function clearPageSelection() { pageSelection = []; lastClickedPage = null; updatePageSelection(); } /** Klick auf die Seitennummer markiert; Umschalt erweitert bis dorthin. */ function togglePageSelect(thumb, shift) { const id = parseInt(thumb.dataset.pageid, 10); const thumbs = Array.from(document.querySelectorAll('#bericht-page-list .page-thumb')); if (shift && lastClickedPage !== null) { const a = thumbs.findIndex(t => parseInt(t.dataset.pageid, 10) === lastClickedPage); const b = thumbs.findIndex(t => parseInt(t.dataset.pageid, 10) === id); if (a >= 0 && b >= 0) { const [from, to] = a < b ? [a, b] : [b, a]; for (let i = from; i <= to; i++) { const pid = parseInt(thumbs[i].dataset.pageid, 10); if (!pageSelection.includes(pid)) pageSelection.push(pid); } updatePageSelection(); return; } } const idx = pageSelection.indexOf(id); if (idx >= 0) pageSelection.splice(idx, 1); else pageSelection.push(id); lastClickedPage = id; updatePageSelection(); } async function runPageBulk(action) { if (!pageSelection.length) return; const n = pageSelection.length; if (action === 'delete') { const ok = await dolConfirm( n === 1 ? 'Die markierte Seite löschen?' : n + ' markierte Seiten löschen?', 'Seiten löschen'); if (!ok) return; } const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); fd.append('action', action); fd.append('pageids', JSON.stringify(pageSelection)); const r = await fetch(cfg.urls.page_bulk, { method: 'POST', body: fd }); const data = await r.json().catch(() => ({})); if (!data.success) { dolAlert('Aktion fehlgeschlagen: ' + (data.error || 'unbekannt')); return; } const deletedCurrent = (action === 'delete' && pageSelection.includes(currentPageId)); clearPageSelection(); await refreshPages(deletedCurrent ? null : currentPageId); await refreshFragments('attachments'); toast(action === 'delete' ? (n === 1 ? 'Seite gelöscht' : n + ' Seiten gelöscht') : (n === 1 ? 'Seite verdoppelt' : n + ' Seiten verdoppelt')); } function bindPageBulk() { const dup = document.getElementById('btn-pages-duplicate'); const del = document.getElementById('btn-pages-delete'); const uns = document.getElementById('btn-pages-unselect'); if (dup) dup.addEventListener('click', () => runPageBulk('duplicate')); if (del) del.addEventListener('click', () => runPageBulk('delete')); if (uns) uns.addEventListener('click', clearPageSelection); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && pageSelection.length && !replaceMode) clearPageSelection(); }); } /* ---------- Ziehen und Ablegen ---------- Zwei Fälle: Dateien vom Rechner auf die Anhänge-Spalte (= hochladen) und Anhang-Kacheln auf die Seitenliste (= neue Seite) oder auf die Arbeitsfläche (= Bild der offenen Seite ersetzen). */ const DND_TYPE = 'application/x-bericht-attachment'; /** DataTransfer.types ist je nach Browser Array oder DOMStringList. */ function hasDragType(e, type) { const types = e.dataTransfer && e.dataTransfer.types; if (!types) return false; return Array.prototype.indexOf.call(types, type) !== -1; } /** Welche Anhänge werden gezogen? Angekreuzte Auswahl hat Vorrang. */ function dragPayload(item) { const selected = getSelectedAttachments(); const isInSelection = selected.some(sel => sel.relpath === item.dataset.relpath); if (selected.length && isInSelection) return selected; return [{ relpath: item.dataset.relpath, mime: item.dataset.mime || '' }]; } function bindAttachmentDragSource() { document.querySelectorAll('#bericht-att-list .bericht-att-item').forEach(item => { item.setAttribute('draggable', 'true'); item.addEventListener('dragstart', (e) => { const payload = dragPayload(item); e.dataTransfer.setData(DND_TYPE, JSON.stringify(payload)); e.dataTransfer.effectAllowed = 'copy'; document.body.classList.add('bericht-dragging-attachment'); item.classList.add('dragging'); const list = document.getElementById('bericht-page-list'); if (list) list.dataset.dropHint = payload.length > 1 ? payload.length + ' Bilder als neue Seiten ablegen' : 'Als neue Seite ablegen'; }); item.addEventListener('dragend', () => { document.body.classList.remove('bericht-dragging-attachment'); item.classList.remove('dragging'); document.querySelectorAll('.drop-active').forEach(el => el.classList.remove('drop-active')); }); }); } /** Liest die gezogenen Anhänge aus, oder null wenn es keine sind. */ function readDragPayload(e) { const raw = e.dataTransfer.getData(DND_TYPE); if (!raw) return null; try { return JSON.parse(raw); } catch (err) { return null; } } /** * Ablegen auf die Seitenliste. Eigene Funktion, weil die Liste beim * Aktualisieren komplett ersetzt wird und die Handler sonst am alten, * längst entfernten Element hängen. */ function bindPageListDropTarget() { const pageList = document.getElementById('bericht-page-list'); if (pageList) { const over = (e) => { if (!hasDragType(e, DND_TYPE)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; pageList.classList.add('drop-active'); }; pageList.addEventListener('dragover', over); pageList.addEventListener('dragenter', over); pageList.addEventListener('dragleave', (e) => { if (!pageList.contains(e.relatedTarget)) pageList.classList.remove('drop-active'); }); pageList.addEventListener('drop', async (e) => { const payload = readDragPayload(e); if (!payload) return; e.preventDefault(); e.stopPropagation(); pageList.classList.remove('drop-active'); for (const sel of payload) { const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); fd.append('relpath', sel.relpath); fd.append('mime', sel.mime); await fetch(cfg.urls.add_attachment, { method: 'POST', body: fd }); } await afterPagesAdded(payload.length); }); } } function bindDropTargets() { const canvasWrap = document.querySelector('.bericht-canvas-wrap'); const attCol = document.querySelector('.bericht-attachments'); bindPageListDropTarget(); // --- Anhang-Kachel auf die Arbeitsfläche: Bild der offenen Seite ersetzen --- if (canvasWrap) { const over = (e) => { if (!hasDragType(e, DND_TYPE) || !currentPageId) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; canvasWrap.classList.add('drop-active'); }; canvasWrap.addEventListener('dragover', over); canvasWrap.addEventListener('dragenter', over); canvasWrap.addEventListener('dragleave', (e) => { if (!canvasWrap.contains(e.relatedTarget)) canvasWrap.classList.remove('drop-active'); }); canvasWrap.addEventListener('drop', async (e) => { const payload = readDragPayload(e); if (!payload || !currentPageId) return; e.preventDefault(); e.stopPropagation(); canvasWrap.classList.remove('drop-active'); if (payload.length > 1) { dolAlert('Auf die Seite lässt sich nur ein Bild ziehen. Für mehrere Bilder auf die Seitenliste rechts ziehen.'); return; } const layout = currentPageEl?.dataset.layout || 'single'; if (layout !== 'single') { dolAlert('Diese Seite hat mehrere Plätze. Bitte über „🔄" auf der Seitenminiatur den Platz wählen.'); return; } const fd = new FormData(); fd.append('token', cfg.token); fd.append('pageid', currentPageId); fd.append('relpath', payload[0].relpath); fd.append('mime', payload[0].mime); const r = await fetch(cfg.urls.replace_page_source, { method: 'POST', body: fd }); const data = await r.json().catch(() => ({})); if (!data.success) { dolAlert('Austausch fehlgeschlagen: ' + (data.error || 'unbekannt')); return; } const keep = currentPageId; await refreshFragments('both'); const thumb = document.querySelector('#bericht-page-list .page-thumb[data-pageid="' + keep + '"]'); if (thumb) await loadPage(thumb); toast('Bild ausgetauscht'); }); } // --- Dateien vom Rechner auf die Anhänge-Spalte: hochladen --- if (attCol) { const isFileDrag = (e) => e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'); const over = (e) => { if (!isFileDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; attCol.classList.add('drop-active'); }; attCol.addEventListener('dragover', over); attCol.addEventListener('dragenter', over); attCol.addEventListener('dragleave', (e) => { if (!attCol.contains(e.relatedTarget)) attCol.classList.remove('drop-active'); }); attCol.addEventListener('drop', async (e) => { if (!isFileDrag(e)) return; e.preventDefault(); attCol.classList.remove('drop-active'); await uploadFiles(Array.from(e.dataTransfer.files || [])); }); } // Ausserhalb der Ziele nichts öffnen — sonst ersetzt der Browser die Seite ['dragover', 'drop'].forEach(ev => { document.addEventListener(ev, (e) => { const t = e.target; if (t.closest && (t.closest('.bericht-attachments') || t.closest('.bericht-canvas-wrap') || t.closest('#bericht-page-list'))) return; if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) e.preventDefault(); }); }); } /* ---------- Breite der Anhänge-Spalte: ziehbar + einklappbar ---------- */ const ATT_WIDTH_KEY = 'bericht.attachments.width.v1'; const ATT_COLLAPSED_KEY = 'bericht.attachments.collapsed.v1'; const ATT_WIDTH_DEFAULT = 260; const ATT_WIDTH_MIN = 150; const ATT_WIDTH_MAX = 620; function setAttWidth(px, remember) { const layout = document.querySelector('.bericht-layout'); if (!layout) return; const w = Math.max(ATT_WIDTH_MIN, Math.min(ATT_WIDTH_MAX, Math.round(px))); layout.style.setProperty('--att-width', w + 'px'); if (remember) { try { localStorage.setItem(ATT_WIDTH_KEY, String(w)); } catch (e) { /* blockiert */ } } return w; } function setAttCollapsed(on) { const layout = document.querySelector('.bericht-layout'); if (!layout) return; layout.classList.toggle('att-collapsed', on); const btn = document.getElementById('att-collapse-btn'); if (btn) { btn.textContent = on ? '»' : '«'; btn.title = on ? 'Anhänge-Spalte ausklappen' : 'Anhänge-Spalte einklappen — mehr Platz für die Seite'; } try { localStorage.setItem(ATT_COLLAPSED_KEY, on ? '1' : '0'); } catch (e) { /* blockiert */ } // Die Arbeitsfläche hat jetzt eine andere Breite clearTimeout(setAttCollapsed._t); setAttCollapsed._t = setTimeout(() => rerenderCurrent(), 250); } function bindAttResize() { const layout = document.querySelector('.bericht-layout'); const splitter = document.getElementById('bericht-splitter'); if (!layout) return; try { const stored = parseInt(localStorage.getItem(ATT_WIDTH_KEY), 10); if (stored) setAttWidth(stored, false); setAttCollapsed(localStorage.getItem(ATT_COLLAPSED_KEY) === '1'); } catch (e) { /* blockiert */ } const btn = document.getElementById('att-collapse-btn'); if (btn) btn.addEventListener('click', () => { setAttCollapsed(!layout.classList.contains('att-collapsed')); }); if (!splitter) return; let startX = 0, startW = 0, dragging = false; const onMove = (e) => { if (!dragging) return; e.preventDefault(); setAttWidth(startW + (e.clientX - startX), false); }; const onUp = () => { if (!dragging) return; dragging = false; splitter.classList.remove('dragging'); document.body.classList.remove('bericht-resizing'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); // Breite sichern und die Seite auf die neue Breite neu zeichnen const cur = parseInt(getComputedStyle(layout).getPropertyValue('--att-width'), 10); setAttWidth(cur, true); rerenderCurrent(); }; splitter.addEventListener('mousedown', (e) => { if (layout.classList.contains('att-collapsed')) return; e.preventDefault(); dragging = true; startX = e.clientX; startW = document.querySelector('.bericht-attachments').getBoundingClientRect().width; splitter.classList.add('dragging'); document.body.classList.add('bericht-resizing'); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); // Doppelklick stellt die Ausgangsbreite wieder her splitter.addEventListener('dblclick', () => { setAttWidth(ATT_WIDTH_DEFAULT, true); rerenderCurrent(); }); } const ATT_SIZE_KEY = 'bericht.attachments.tilesize.v1'; const TILE_SIZES = ['s', 'm', 'l']; /** Kachelgröße umschalten — klein/mittel/groß, gesteuert über eine CSS-Variable. */ function applyTileSize(size) { const list = document.getElementById('bericht-att-list'); if (!list) return; if (!TILE_SIZES.includes(size)) size = 'm'; list.dataset.tilesize = size; const btn = document.getElementById('att-size-btn'); if (btn) btn.title = 'Kachelgröße: ' + ({s: 'klein', m: 'mittel', l: 'groß'})[size] + ' — klicken zum Wechseln'; } function bindTileSize() { let size = 'm'; try { size = localStorage.getItem(ATT_SIZE_KEY) || 'm'; } catch (e) { /* blockiert */ } applyTileSize(size); const btn = document.getElementById('att-size-btn'); if (btn) btn.addEventListener('click', () => { const cur = document.getElementById('bericht-att-list')?.dataset.tilesize || 'm'; const next = TILE_SIZES[(TILE_SIZES.indexOf(cur) + 1) % TILE_SIZES.length]; applyTileSize(next); try { localStorage.setItem(ATT_SIZE_KEY, next); } catch (e) { /* blockiert */ } }); } function applyAttView(view) { const list = document.getElementById('bericht-att-list'); if (!list) return; const isList = (view === 'list'); list.classList.toggle('att-view-list', isList); list.classList.toggle('att-view-grid', !isList); document.querySelectorAll('.att-view-btn').forEach(b => { b.classList.toggle('active', b.dataset.view === (isList ? 'list' : 'grid')); }); } /** CSS.escape mit Fallback für ältere Browser */ function cssEscape(v) { if (window.CSS && typeof CSS.escape === 'function') return CSS.escape(v); return String(v).replace(/["\\]/g, '\\$&'); } /** * Öffnet die Lightbox. Navigiert innerhalb der Herkunfts-Gruppe * (Bilder und PDFs gemischt, in der angezeigten Reihenfolge). */ function openAttachmentViewer(thumb) { if (!attViewer) return; const item = thumb.closest('.bericht-att-item'); const group = item ? item.dataset.group : ''; const list = document.getElementById('bericht-att-list'); const siblings = Array.from( list.querySelectorAll('.bericht-att-item[data-group="' + cssEscape(group) + '"] .att-thumb') ).filter(t => t.dataset.type !== 'other'); const entries = siblings.map(t => ({ url: t.dataset.viewUrl, title: t.dataset.filename || '', type: t.dataset.type === 'pdf' ? 'pdf' : 'image' })); let startIndex = siblings.indexOf(thumb); if (startIndex < 0) startIndex = 0; attViewer.open(entries, startIndex); } /** * PDF-Anhänge: Seite 1 per PDF.js in die Kachel rendern. * Serverseitig geht das nicht — im Prod-Container ist Imagick nicht installiert. */ function lazyRenderPdfThumbs(list) { const canvases = Array.from(list.querySelectorAll('.att-thumb-canvas[data-pdf-url]')); if (!canvases.length || !window.pdfjsLib) return; const render = async (canvas) => { if (canvas.dataset.rendered) return; canvas.dataset.rendered = '1'; try { const r = await fetch(canvas.dataset.pdfUrl, { credentials: 'same-origin' }); if (!r.ok) throw new Error('HTTP ' + r.status); const buf = await r.arrayBuffer(); const doc = await pdfjsLib.getDocument({ data: buf }).promise; const page = await doc.getPage(1); const base = page.getViewport({ scale: 1 }); const scale = 220 / base.width; const vp = page.getViewport({ scale: scale }); canvas.width = Math.round(vp.width); canvas.height = Math.round(vp.height); await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise; } catch (e) { // Kein Bild möglich — Kachel behält das PDF-Badge canvas.dataset.rendered = 'failed'; } }; if (typeof IntersectionObserver === 'undefined') { canvases.forEach(render); return; } const io = new IntersectionObserver((entries, obs) => { entries.forEach(en => { if (!en.isIntersecting) return; obs.unobserve(en.target); render(en.target); }); }, { root: document.querySelector('.bericht-attachments'), rootMargin: '200px' }); canvases.forEach(c => io.observe(c)); } /** * Auswahl-Status neu berechnen: Rahmen, Reihenfolge-Nummer, Zähler. * attSelection hält die Klick-Reihenfolge — sie bestimmt, in welcher * Reihenfolge die Bilder in die Grid-Slots wandern. */ function updateAttSelection() { const list = document.getElementById('bericht-att-list'); if (!list) return; const checkedPaths = Array.from(list.querySelectorAll('.att-check:checked')).map(cb => cb.dataset.relpath); // abgewählte entfernen, neu dazugekommene hinten anhängen attSelection = attSelection.filter(p => checkedPaths.includes(p)); checkedPaths.forEach(p => { if (!attSelection.includes(p)) attSelection.push(p); }); list.querySelectorAll('.bericht-att-item').forEach(item => { const cb = item.querySelector('.att-check'); const on = !!(cb && cb.checked); item.classList.toggle('selected', on); const order = item.querySelector('.att-order'); if (order) order.textContent = on ? String(attSelection.indexOf(item.dataset.relpath) + 1) : ''; }); const counter = document.getElementById('att-selected-count'); if (counter) counter.textContent = String(attSelection.length); } /* ---------- Listen aktualisieren statt die Seite neu zu laden ---------- Ein location.reload() warf den Editor auf Seite 1 zurück, verwarf Zoom und Auswahl und lud alles neu — bei 20 Fotos hintereinander war das zäh. */ let sortableInstance = null; async function refreshFragments(what) { try { const r = await fetch(cfg.urls.fragments + '?berichtid=' + cfg.berichtid + '&what=' + what, { credentials: 'same-origin' }); const data = await r.json(); if (!data.success) return null; if (data.pages_html) { const list = document.getElementById('bericht-page-list'); if (list) { // Hell/Dunkel-Zustand der Miniaturen über den Austausch retten const paperDark = list.classList.contains('paper-dark'); list.outerHTML = data.pages_html; const fresh = document.getElementById('bericht-page-list'); if (fresh && paperDark) { fresh.classList.remove('paper-light'); fresh.classList.add('paper-dark'); } bindThumbs(); bindSortable(); bindPageListDropTarget(); updatePageSelection(); await renderAllThumbs(); } } if (data.attachments_html) { const list = document.getElementById('bericht-att-list'); if (list) { const size = list.dataset.tilesize || 'm'; list.outerHTML = data.attachments_html; bindAttachmentEvents(); applyAttView(document.querySelector('.att-view-btn.active')?.dataset.view || 'grid'); applyTileSize(size); } } if (typeof data.page_count === 'number') { const c1 = document.getElementById('page-count'); const c2 = document.getElementById('meta-page-count'); if (c1) c1.textContent = String(data.page_count); if (c2) c2.textContent = String(data.page_count); const wrap = document.querySelector('.bericht-canvas-wrap'); if (wrap) wrap.classList.toggle('empty', data.page_count === 0); } return data; } catch (e) { console.error('Aktualisierung fehlgeschlagen:', e); return null; } } /** Seitenliste neu holen und die zuvor aktive Seite wieder markieren. */ async function refreshPages(keepPageId) { const wanted = keepPageId || currentPageId; const data = await refreshFragments('pages'); if (!data) return; const thumbs = Array.from(document.querySelectorAll('#bericht-page-list .page-thumb')); const still = thumbs.find(t => parseInt(t.dataset.pageid, 10) === wanted); if (still) { still.classList.add('active'); currentPageEl = still; } else if (thumbs.length) { // Die bearbeitete Seite gibt es nicht mehr (gelöscht) — Nachbarn nehmen await loadPage(thumbs[0]); } else { currentPageId = null; currentPageEl = null; fabricCanvas.clear(); } } /** * Nach dem Anlegen neuer Seiten: Listen aktualisieren, Auswahl aufheben und * ans Ende der Seitenliste scrollen — die aktuell bearbeitete Seite bleibt offen. */ async function afterPagesAdded(count) { document.querySelectorAll('#bericht-att-list .att-check:checked').forEach(cb => { cb.checked = false; }); attSelection = []; await refreshFragments('both'); const thumbs = document.querySelectorAll('#bericht-page-list .page-thumb'); if (thumbs.length) { const last = thumbs[thumbs.length - 1]; last.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); // Wenn vorher gar keine Seite offen war, die erste neue öffnen if (!currentPageId) await loadPage(thumbs[0]); } toast(count === 1 ? 'Seite hinzugefügt' : count + ' Seiten hinzugefügt'); } /* ---------- Bild einer bestehenden Seite austauschen ---------- */ // null = kein Austausch aktiv; sonst { pageid, layout, slot } let replaceMode = null; function startReplace(pageThumb) { const pageid = pageThumb.dataset.pageid; const layout = pageThumb.dataset.layout || 'single'; const slots = { single: 1, grid_2: 2, grid_2v: 2, grid_4: 4, grid_6: 6, before_after: 2, title_only: 0 }[layout] || 1; if (slots === 0) { dolAlert('Diese Seite hat kein Bild zum Austauschen.'); return; } replaceMode = { pageid: pageid, layout: layout, slot: (slots > 1 ? null : 0) }; const nr = Array.from(document.querySelectorAll('#bericht-page-list .page-thumb')).indexOf(pageThumb) + 1; const label = document.getElementById('replace-page-label'); if (label) label.textContent = 'Seite ' + nr; document.querySelectorAll('#bericht-page-list .page-thumb').forEach(t => { t.classList.toggle('replace-target', t === pageThumb); }); // Bei Rastern zuerst den Platz wählen lassen const picker = document.getElementById('replace-slot-picker'); const btns = document.getElementById('replace-slot-buttons'); if (slots > 1 && picker && btns) { btns.innerHTML = ''; for (let i = 0; i < slots; i++) { const b = document.createElement('button'); b.type = 'button'; b.className = 'replace-slot-btn'; b.textContent = String(i + 1); b.title = 'Platz ' + (i + 1) + ' (von links oben nach rechts unten gezählt)'; b.addEventListener('click', () => { replaceMode.slot = i; btns.querySelectorAll('.replace-slot-btn').forEach(x => x.classList.remove('active')); b.classList.add('active'); }); btns.appendChild(b); } picker.style.display = ''; } else if (picker) { picker.style.display = 'none'; } const hint = document.getElementById('bericht-replace-hint'); if (hint) hint.style.display = ''; document.body.classList.add('bericht-replacing'); // Anhänge-Spalte in den Blick holen — die Statusleiste unten bleibt // ohnehin sichtbar, aber die Kacheln sollen erreichbar sein const attCol = document.querySelector('.bericht-attachments'); if (attCol) attCol.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } function cancelReplace() { replaceMode = null; const hint = document.getElementById('bericht-replace-hint'); if (hint) hint.style.display = 'none'; document.body.classList.remove('bericht-replacing'); document.querySelectorAll('#bericht-page-list .page-thumb.replace-target') .forEach(t => t.classList.remove('replace-target')); } /** Führt den Austausch mit dem angeklickten Anhang aus. */ async function applyReplace(item) { if (!replaceMode) return; const relpath = item.dataset.relpath; const mime = item.dataset.mime || ''; if (replaceMode.slot === null) { dolAlert('Bitte zuerst oben den Platz wählen, der ersetzt werden soll.'); return; } if (replaceMode.layout !== 'single' && !mime.startsWith('image')) { dolAlert('In Raster-Layouts können nur Bilder eingesetzt werden.'); return; } const fd = new FormData(); fd.append('token', cfg.token); fd.append('pageid', replaceMode.pageid); fd.append('relpath', relpath); fd.append('mime', mime); if (replaceMode.layout !== 'single') fd.append('slot', replaceMode.slot); const replacedId = replaceMode.pageid; const r = await fetch(cfg.urls.replace_page_source, { method: 'POST', body: fd }); const data = await r.json().catch(() => ({})); if (!data.success) { dolAlert('Austausch fehlgeschlagen: ' + (data.error || 'unbekannt')); return; } cancelReplace(); toast('Bild ausgetauscht'); await refreshFragments('both'); // Die bearbeitete Seite neu zeichnen, damit das neue Bild sofort zu sehen ist const thumb = document.querySelector('#bericht-page-list .page-thumb[data-pageid="' + replacedId + '"]'); if (thumb) await loadPage(thumb); } function bindReplaceControls() { const cancel = document.getElementById('btn-cancel-replace'); if (cancel) cancel.addEventListener('click', cancelReplace); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && replaceMode) cancelReplace(); }); } /** Ausgewählte Anhänge in Klick-Reihenfolge, als {relpath, mime} */ function getSelectedAttachments() { const list = document.getElementById('bericht-att-list'); if (!list) return []; return attSelection.map(relpath => { const cb = list.querySelector('.att-check[data-relpath="' + cssEscape(relpath) + '"]'); return cb ? { relpath: relpath, mime: cb.dataset.mime || '' } : null; }).filter(Boolean); } /* ---------- 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(); bindAttachmentPanel(); bindReplaceControls(); bindMarginToggle(); bindDirtyTracking(); bindKeyboardShortcuts(); // 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 ---------- */ /* Seitenwechsel werden serialisiert. Klickt man schnell durch die Miniaturen, überlappten sich sonst zwei Durchläufe: der zweite speicherte die noch nicht umgeschaltete Seite ein zweites Mal, und beide rendern in denselben Canvas — die Notiz der einen Seite konnte bei der anderen landen. Jeder Aufruf bekommt eine Nummer; ist zwischendurch ein neuerer gestartet, bricht der ältere ab. */ let pageSwitchChain = Promise.resolve(); let pageSwitchToken = 0; function loadPage(thumbEl) { const token = ++pageSwitchToken; pageSwitchChain = pageSwitchChain .catch(() => {}) // ein Fehler darf die Kette nicht sprengen .then(() => loadPageInternal(thumbEl, token)); return pageSwitchChain; } async function loadPageInternal(thumbEl, token) { // vorher: aktuelle Seite speichern clearTimeout(autosaveTimer); if (currentPageId) await savePageAnnotations(false); if (token !== pageSwitchToken) return; // inzwischen wurde weitergeklickt dirty = false; updateDirtyIndicator(); buildingPage = true; 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(); if (token !== pageSwitchToken) { buildingPage = false; return; } 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(); setNoteValue(''); await loadPageMeta(); // setzt currentPageRotation + ggf. loadedFabricJson if (token !== pageSwitchToken) { buildingPage = false; return; } // Canvas = Inhaltsbereich der Seite (das, was später gedruckt wird) const target = getTargetCanvasWidth(); const aspect = contentAspect(); const isLandscape = currentPageRotation === 90 || currentPageRotation === 270; pdfCanvas.width = target; pdfCanvas.height = isLandscape ? Math.round(target * aspect) : Math.round(target / aspect); const pctx = pdfCanvas.getContext('2d'); pctx.fillStyle = '#ffffff'; pctx.fillRect(0, 0, pdfCanvas.width, pdfCanvas.height); applySheetGeometry(pdfCanvas.width, pdfCanvas.height); resizeFabricToCanvas(); // Quellbild IMMER frisch laden — gespeicherte bgImage-Objekte haben eine // blob:-URL, die nach Reload ungültig ist. await rerenderCurrent(); if (token !== pageSwitchToken) { buildingPage = false; return; } // Overlay-Shapes (Pfeile, Text, Rechtecke, …) aus JSON wiederherstellen, // aber ohne bgImage/type=image-Einträge (Legacy-Cleanup). if (loadedFabricJson) { try { const parsed = (typeof loadedFabricJson === 'string') ? JSON.parse(loadedFabricJson) : loadedFabricJson; if (parsed && Array.isArray(parsed.objects) && parsed.objects.length > 0) { const bg = fabricCanvas.getObjects().find(o => o.bgImage === true); const overlays = parsed.objects.filter(o => !o.bgImage && o.type !== 'image'); if (overlays.length > 0) { await new Promise((res) => { fabric.util.enlivenObjects(overlays, (objs) => { objs.forEach(o => fabricCanvas.add(o)); if (bg) bg.sendToBack(); fabricCanvas.requestRenderAll(); res(); }); }); applyTool(); } } } catch (e) { console.warn('Fabric-JSON restore fehlgeschlagen:', e); } } buildingPage = false; dirty = false; updateDirtyIndicator(); renderSlotOverlay(); } /** * Rendert die aktuelle Seite (image oder pdf) aus dem Buffer mit currentPageRotation. */ async function rerenderCurrent() { if (!currentPageBuffer) return; // Auch Zoomen und Fenstergrößen-Änderungen bauen den Canvas neu auf, // ohne dass der Anwender etwas geändert hat. const wasBuilding = buildingPage; buildingPage = true; try { if (currentPageMime.includes('pdf')) { await renderPdf(currentPageBuffer); } else if (currentPageMime.includes('image')) { await renderImage(currentPageBuffer, currentPageMime); } } finally { buildingPage = wasBuilding; } } /** * 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; // Das Blatt ist breiter als der Inhaltsbereich — die Seitenränder // müssen mit in die verfügbare Breite passen. const g = pageGeometry(); const sheetFactor = g.contentW / g.pageW; const base = Math.max(300, Math.min(1200, Math.floor(avail * sheetFactor))); 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; // Eingebundene PDF-Seiten werden unveraendert uebernommen und haben // ihren eigenen Rand — hier keine Blatt-Zonen darüberlegen. setSheetZonesVisible(false); 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(); // Canvas-Grundfläche = Inhaltsbereich der Seite (ohne Kopf-/Fußzone und Ränder) const aspect = contentAspect(); // Breite:Höhe const isLandscape = currentPageRotation === 90 || currentPageRotation === 270; const canvasW = target; const canvasH = isLandscape ? Math.round(target * aspect) : Math.round(target / aspect); // 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); applySheetGeometry(canvasW, canvasH); resizeFabricToCanvas(); // Bild als Fabric-Image laden const blob = new Blob([arrayBuffer], { type: mime }); const url = URL.createObjectURL(blob); // Native Image-Load + fabric.Image(element) — kein CORS auf Blob-URLs, // bessere Fehler-Diagnose, Timeout-Fallback return new Promise((res) => { const htmlImg = new Image(); const tid = setTimeout(() => { console.warn('[renderImage] Timeout beim Bild-Laden nach 10s — URL:', url); URL.revokeObjectURL(url); res(); }, 10000); htmlImg.onload = () => { clearTimeout(tid); try { const fabricImg = new fabric.Image(htmlImg); const existing = fabricCanvas.getObjects().find(o => o.bgImage === true); if (existing) fabricCanvas.remove(existing); fabricImg.bgImage = true; 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, }); fabricCanvas.add(fabricImg); fabricImg.sendToBack(); fabricCanvas.requestRenderAll(); if (typeof applyTool === 'function') applyTool(); } catch (e) { console.error('[renderImage] Fabric-Wrap fehlgeschlagen:', e); } URL.revokeObjectURL(url); res(); }; htmlImg.onerror = (err) => { clearTimeout(tid); console.error('[renderImage] htmlImg onerror:', err, 'mime:', mime, 'bufSize:', arrayBuffer.byteLength); URL.revokeObjectURL(url); res(); }; htmlImg.src = url; }); } function resizeFabricToCanvas() { fabricCanvas.setWidth(pdfCanvas.width); fabricCanvas.setHeight(pdfCanvas.height); // Das Platz-Gitter liegt deckungsgleich auf dem Canvas requestAnimationFrame(() => renderSlotOverlay()); 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) setNoteValue(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; // Die Optionen heissen "1.0"/"0.7"/... — ein DB-Wert "1" trifft sonst // keine Option und das Auswahlfeld bleibt leer. if (sEl) sEl.value = currentPageScale.toFixed(1); if (aEl) aEl.value = currentPageAlign; document.querySelectorAll('.single-only').forEach(el => { el.style.display = (currentPageLayout === 'single') ? '' : 'none'; }); } catch (e) { /* ok */ } } /* ---------- Tastenkürzel ---------- */ // Werkzeug-Taste -> data-tool der Schaltfläche const SHORTCUT_TOOLS = { v: 'select', p: 'draw', r: 'rect', k: 'circle', a: 'arrow', t: 'text', }; /** Tippt der Anwender gerade in ein Eingabefeld? Dann keine Kürzel abfangen. */ function isTypingTarget(el) { if (!el) return false; if (el.isContentEditable) return true; const tag = el.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; } function bindKeyboardShortcuts() { document.addEventListener('keydown', async (e) => { // CKEditor lebt in einem iframe — dort landen die Tasten gar nicht hier, // aber Dolibarr-Felder auf der Karte schon. if (isTypingTarget(e.target)) return; if (document.querySelector('.bericht-dolmodal')) return; // Dialog offen if (document.querySelector('.bericht-viewer-overlay.active')) return; // Großansicht offen const key = e.key.toLowerCase(); // Strg/Cmd-Kombinationen if (e.ctrlKey || e.metaKey) { if (key === 's') { e.preventDefault(); await saveMeta(); await savePageAnnotations(true); } else if (key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); } else if (key === 'y' || (key === 'z' && e.shiftKey)) { e.preventDefault(); redo(); } return; } if (e.altKey) return; // Werkzeugwahl if (SHORTCUT_TOOLS[key]) { const btn = document.querySelector('.tool-btn[data-tool="' + SHORTCUT_TOOLS[key] + '"]'); if (btn) { e.preventDefault(); btn.click(); } return; } // Ausgewähltes Objekt löschen if (key === 'delete' || key === 'backspace') { const sel = fabricCanvas.getActiveObjects(); // Das Hintergrundbild nicht versehentlich entfernen const removable = sel.filter(o => !o.bgImage); if (removable.length) { e.preventDefault(); removable.forEach(o => fabricCanvas.remove(o)); fabricCanvas.discardActiveObject(); fabricCanvas.requestRenderAll(); } return; } // Seitenwechsel mit den Pfeiltasten if (key === 'arrowdown' || key === 'arrowup') { const thumbs = Array.from(document.querySelectorAll('#bericht-page-list .page-thumb')); const idx = thumbs.findIndex(t => parseInt(t.dataset.pageid, 10) === currentPageId); const next = (key === 'arrowdown') ? idx + 1 : idx - 1; if (idx >= 0 && next >= 0 && next < thumbs.length) { e.preventDefault(); thumbs[next].scrollIntoView({ block: 'nearest' }); await loadPage(thumbs[next]); } } }); } /* ---------- 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'); // beim Umschalten des Layouts muss das Platz-Gitter mitziehen 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(); // Seitenminiatur und Platz-Gitter an das neue Layout anpassen if (currentPageEl) currentPageEl.dataset.layout = currentPageLayout; renderSlotOverlay(); }); 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'); // Die Platz-Schaltflächen liegen über dem Zeichen-Canvas — sie dürfen // nur im Auswahlmodus reagieren, sonst kann man auf Raster-Seiten // nicht mehr zeichnen. updateSlotOverlayState(); fabricCanvas.selection = (currentTool === 'select'); // Cursor-Hint fabricCanvas.defaultCursor = (currentTool === 'select') ? 'default' : 'crosshair'; // Wenn ein Zeichen-Tool aktiv ist, sperren wir Bilder und bestehende Shapes, // damit sie nicht versehentlich verschoben werden. Bei 'select' wird alles // wieder ziehbar. const isSelect = (currentTool === 'select'); fabricCanvas.getObjects().forEach(obj => { obj.set({ selectable: isSelect, evented: isSelect, }); }); if (!isSelect) fabricCanvas.discardActiveObject(); fabricCanvas.requestRenderAll(); 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(); } } /* ---------- Ungespeicherte Änderungen: Autosave + Warnung ---------- Vorher gab es nur den Knopf "Entwurf speichern" — wer ihn vergessen hat, war die Arbeit an der Seite los. */ const AUTOSAVE_DELAY = 4000; // ms Ruhe nach der letzten Änderung let dirty = false; // Beim Aufbauen einer Seite feuert Fabric object:added — das ist keine // Änderung des Anwenders und darf den Speicherstand nicht verfälschen. let buildingPage = false; let autosaveTimer = null; let autosaveRunning = false; /** Markiert die Seite als geändert und plant das automatische Speichern. */ function markDirty() { if (!currentPageId || buildingPage) return; dirty = true; updateDirtyIndicator(); clearTimeout(autosaveTimer); autosaveTimer = setTimeout(() => { autosave(); }, AUTOSAVE_DELAY); } async function autosave() { if (!dirty || !currentPageId || autosaveRunning) return; autosaveRunning = true; try { await savePageAnnotations(false); dirty = false; updateDirtyIndicator('saved'); } catch (e) { console.warn('Automatisches Speichern fehlgeschlagen:', e); } finally { autosaveRunning = false; } } /** Zeigt in der Fußleiste an, ob noch etwas offen ist. */ function updateDirtyIndicator(state) { const el = document.getElementById('bericht-save-state'); if (!el) return; if (state === 'saved') { el.textContent = '✓ gespeichert'; el.className = 'save-state saved'; clearTimeout(el._hideTimer); el._hideTimer = setTimeout(() => { if (!dirty) { el.textContent = ''; el.className = 'save-state'; } }, 4000); } else if (dirty) { el.textContent = '● nicht gespeichert'; el.className = 'save-state dirty'; } else { el.textContent = ''; el.className = 'save-state'; } } function bindDirtyTracking() { // Zeichnen/Verschieben/Löschen im Canvas const events = ['object:added', 'object:modified', 'object:removed', 'path:created']; events.forEach(ev => fabricCanvas.on(ev, () => markDirty())); // Notizfeld (CKEditor oder einfaches Textfeld) const hookNote = () => { if (window.CKEDITOR && window.CKEDITOR.instances && window.CKEDITOR.instances['page-note']) { window.CKEDITOR.instances['page-note'].on('change', () => markDirty()); return true; } const el = document.getElementById('page-note'); if (el) { el.addEventListener('input', () => markDirty()); return true; } return false; }; if (!hookNote()) setTimeout(hookNote, 1500); // Beim Verlassen warnen, wenn noch etwas offen ist window.addEventListener('beforeunload', (e) => { if (!dirty) return; e.preventDefault(); e.returnValue = ''; return ''; }); // Tab-Wechsel/Minimieren: lieber jetzt sichern als später verlieren document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && dirty) autosave(); }); } /* ---------- Speichern ---------- */ async function savePageAnnotations(showMessage = true) { if (!currentPageId) return; // Sicherheit: wenn kein bgImage im Canvas ist, NICHT speichern — sonst // überschreiben wir einen funktionierenden Bericht mit einem leeren // Composite const hasBg = fabricCanvas.getObjects().some(o => o.bgImage === true || o.type === 'image'); if (!hasBg) { console.warn('[savePageAnnotations] Kein Bild im Canvas — skip, um Datenverlust zu vermeiden'); if (showMessage) toast('Speichern übersprungen (kein Bild geladen)', 'warn'); return; } const fd = new FormData(); fd.append('token', cfg.token); fd.append('pageid', currentPageId); // WICHTIG: bgImage-Objekte nicht serialisieren — ihre src ist eine // blob:-URL, die nach Reload ungültig ist und zum weißen Canvas führt. // Das Quellbild wird beim Laden frisch aus page_image.php geholt. const jsonOut = fabricCanvas.toJSON(['bgImage']); if (jsonOut && Array.isArray(jsonOut.objects)) { jsonOut.objects = jsonOut.objects.filter(o => !o.bgImage); } fd.append('fabric_json', JSON.stringify(jsonOut)); fd.append('note', getNoteValue() || ''); fd.append('rotation', currentPageRotation); // Phase 6: Composite-PNG 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 (data.success) { dirty = false; clearTimeout(autosaveTimer); updateDirtyIndicator('saved'); } 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 = await dolPrompt('Label für die Vorlage (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 dolAlert('Fehler: ' + (data.error || '')); }); } document.getElementById('btn-finalize').addEventListener('click', async () => { if (!(await dolConfirm('Bericht jetzt finalisieren und PDF erzeugen?'))) return; toast('Speichere aktuelle Seite…'); await savePageAnnotations(false); toast('PDF wird erzeugt…'); try { 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 txt = await r.text(); let data = {}; try { data = JSON.parse(txt); } catch (e) { console.error('generate_pdf lieferte kein JSON:', txt); toast('Server-Fehler (kein JSON)', 'error'); return; } if (data.success) { toast('✓ PDF erstellt: ' + data.filename); // Hier ist ein echter Reload richtig: der Bericht wechselt auf // "finalisiert" und die ganze Karte sieht danach anders aus. setTimeout(() => location.reload(), 1500); } else { console.error('generate_pdf failed:', data); toast('Fehler: ' + (data.error || 'unbekannt'), 'error'); } } catch (e) { console.error('finalize exception:', e); toast('Netzwerk-Fehler: ' + e.message, 'error'); } }); } /* ---------- Anhänge & Thumbs ---------- */ function bindAttachments() { const btn = document.getElementById('btn-add-selected'); const layoutSel = document.getElementById('add-selected-layout'); if (btn) { btn.addEventListener('click', async () => { // Reihenfolge = Klick-Reihenfolge der Auswahl (① ② ③), nicht DOM-Reihenfolge const selected = getSelectedAttachments(); if (!selected.length) { dolAlert('Bitte zuerst Bilder auswählen'); return; } const layout = layoutSel ? layoutSel.value : 'single'; if (layout === 'single') { // Wie bisher: jedes Bild als eigene Seite for (const sel of selected) { const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); fd.append('relpath', sel.relpath); fd.append('mime', sel.mime); await fetch(cfg.urls.add_attachment, { method: 'POST', body: fd }); } await afterPagesAdded(selected.length); return; } // Grid-Layout: in Gruppen aufteilen const slotCount = { grid_2: 2, grid_2v: 2, grid_4: 4, grid_6: 6, before_after: 2 }[layout] || 4; const imageSel = selected.filter(sel => sel.mime.startsWith('image')); if (!imageSel.length) { dolAlert('Bitte mindestens ein Bild auswählen (PDFs nicht in Grids unterstützt)'); return; } // In Gruppen à slotCount aufteilen for (let i = 0; i < imageSel.length; i += slotCount) { const group = imageSel.slice(i, i + slotCount).map(sel => sel.relpath); const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); fd.append('layout', layout); fd.append('relpaths', JSON.stringify(group)); const r = await fetch(cfg.urls.create_grid_page, { method: 'POST', body: fd }); const data = await r.json().catch(() => ({})); if (!data.success) { dolAlert('Fehler bei Gruppe '+(Math.floor(i/slotCount)+1)+': '+(data.error || 'unbekannt')); return; } } await afterPagesAdded(Math.ceil(imageSel.length / slotCount)); }); } } /** * Lädt Dateien nacheinander hoch — genutzt vom Dateidialog und vom Ablegen * per Drag & Drop. Fortschritt steht auf der Schaltfläche. */ async function uploadFiles(files) { const allowed = /\.(pdf|png|jpe?g|webp)$/i; const rejected = files.filter(f => !allowed.test(f.name)); files = files.filter(f => allowed.test(f.name)); if (rejected.length) { await dolAlert('Nicht unterstützt und übersprungen:\n' + rejected.map(f => f.name).join('\n') + '\n\nMöglich sind PDF, JPG, PNG und WebP.'); } if (!files.length) return; const label = document.querySelector('label[for="bericht-extra-upload"]'); const labelText = label ? label.textContent : ''; const failed = []; for (let i = 0; i < files.length; i++) { if (label) label.textContent = '⏳ ' + (i + 1) + ' / ' + files.length; const fd = new FormData(); fd.append('token', cfg.token); fd.append('berichtid', cfg.berichtid); fd.append('file', files[i]); try { const r = await fetch(cfg.urls.upload_extra, { method: 'POST', body: fd }); const data = await r.json(); if (!data.success) failed.push(files[i].name + ': ' + (data.error || 'unbekannt')); } catch (e) { failed.push(files[i].name + ': ' + e.message); } } if (label) label.textContent = labelText; if (failed.length) { await dolAlert('Upload fehlgeschlagen:\n' + failed.join('\n')); if (failed.length === files.length) return; } await refreshFragments('attachments'); toast((files.length - failed.length) + ' Datei(en) hochgeladen'); } function bindExtraUpload() { const inp = document.getElementById('bericht-extra-upload'); if (inp) { inp.addEventListener('change', async () => { const files = Array.from(inp.files || []); if (!files.length) return; await uploadFiles(files); inp.value = ''; }); } // 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('element_id', cfg.element_id); fd.append('element_type', cfg.element_type); const r = await fetch(cfg.urls.create_upload_token, { method: 'POST', body: fd }); const data = await r.json(); if (!data.success) { dolAlert('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: Fotos landen jetzt im Auftragsordner, nicht als Pages. // Prüfe ob sich die Anhänge-Anzahl ändert → Seite neu laden für Anhänge-Browser-Refresh. qrLastPageCount = document.querySelectorAll('.attachment-item').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(); // Fallback: auch auf Pages prüfen (bestehende Flows), primär aber Anhänge-Änderung const currentAttachments = document.querySelectorAll('.attachment-item').length; if (currentAttachments !== qrLastPageCount || (d.success && d.count !== document.querySelectorAll('.page-thumb').length)) { document.querySelector('.qr-status').textContent = '✓ Neue Fotos hochgeladen'; clearInterval(qrPollInterval); refreshFragments('both'); } } 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') || e.target.closest('.thumb-replace')) return; if (e.target.closest('.page-num')) { // Nummer = markieren e.stopPropagation(); togglePageSelect(t, e.shiftKey); return; } loadPage(t); }); const rep = t.querySelector('.thumb-replace'); if (rep) rep.addEventListener('click', (e) => { e.stopPropagation(); startReplace(t); }); const del = t.querySelector('.thumb-del'); if (del) del.addEventListener('click', async (e) => { e.stopPropagation(); if (!(await dolConfirm(cfg.lang.confirm_del))) return; const fd = new FormData(); fd.append('token', cfg.token); fd.append('pageid', t.dataset.pageid); const wasCurrent = (parseInt(t.dataset.pageid, 10) === currentPageId); await fetch(cfg.urls.delete_page, { method: 'POST', body: fd }); await refreshPages(wasCurrent ? null : currentPageId); await refreshFragments('attachments'); // sonst bleibt die ✓-Markierung stehen toast('Seite gelöscht'); }); }); // 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) { dolAlert('Fehler: ' + (data.error || 'unbekannt')); return; } showSignatureVerifyResult(data); } catch (err) { btn.textContent = '🔍'; dolAlert('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; // Nach einem Neuaufbau der Liste haengt die alte Instanz an einem // entfernten Element — sonst reagiert das Ziehen doppelt. if (sortableInstance) { try { sortableInstance.destroy(); } catch (e) { /* weg */ } } sortableInstance = Sortable.create(list, { animation: 150, // Nur untereinander sortieren; Anhang-Kacheln behandeln wir selbst group: { name: 'bericht-pages', pull: false, put: false }, 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); } /* ---------- Dolibarr-Style Modal-Dialoge (ersetzen alert/confirm/prompt) ---------- */ function dolModal(opts) { // opts: { title, body, buttons:[{label, value, primary}], input:boolean, defaultValue } return new Promise((resolve) => { const ov = document.createElement('div'); ov.className = 'bericht-dolmodal-overlay'; const box = document.createElement('div'); box.className = 'bericht-dolmodal'; const h = document.createElement('div'); h.className = 'bericht-dolmodal-title'; h.textContent = opts.title || 'Bericht'; const b = document.createElement('div'); b.className = 'bericht-dolmodal-body'; b.textContent = opts.body || ''; let inputEl = null; if (opts.input) { inputEl = document.createElement('input'); inputEl.type = 'text'; inputEl.className = 'bericht-dolmodal-input'; inputEl.value = opts.defaultValue || ''; b.appendChild(document.createElement('br')); b.appendChild(inputEl); } const foot = document.createElement('div'); foot.className = 'bericht-dolmodal-foot'; (opts.buttons || [{label:'OK', value:true, primary:true}]).forEach(btn => { const el = document.createElement('button'); el.type = 'button'; el.className = btn.primary ? 'butAction butActionConfirm' : 'butAction'; el.textContent = btn.label; el.addEventListener('click', () => { const val = opts.input ? (btn.value ? (inputEl.value || null) : null) : btn.value; ov.remove(); resolve(val); }); foot.appendChild(el); }); box.appendChild(h); box.appendChild(b); box.appendChild(foot); ov.appendChild(box); document.body.appendChild(ov); if (inputEl) { inputEl.focus(); inputEl.select(); } ov.addEventListener('click', (e) => { if (e.target === ov) { ov.remove(); resolve(opts.input ? null : false); } }); }); } async function dolAlert(msg, title) { return dolModal({ title: title || 'Hinweis', body: msg, buttons: [{label:'OK', value:true, primary:true}], }); } async function dolConfirm(msg, title) { return dolModal({ title: title || 'Bestätigen', body: msg, buttons: [ {label:'Abbrechen', value:false}, {label:'OK', value:true, primary:true}, ], }); } async function dolPrompt(msg, defaultValue, title) { return dolModal({ title: title || 'Eingabe', body: msg, input: true, defaultValue: defaultValue || '', buttons: [ {label:'Abbrechen', value:false}, {label:'OK', value:true, primary:true}, ], }); } // Globaler Handler: Links/Buttons mit data-dolconfirm abfangen document.addEventListener('click', async (e) => { const el = e.target.closest('[data-dolconfirm]'); if (!el) return; if (el.dataset._dolconfirmed === '1') return; // schon durchgelaufen e.preventDefault(); e.stopPropagation(); const ok = await dolConfirm(el.getAttribute('data-dolconfirm')); if (!ok) return; el.dataset._dolconfirmed = '1'; if (el.tagName === 'A') { window.location.href = el.getAttribute('href'); } else { el.click(); } }, true); // Dolibarr injiziert eine globale leere jQuery-UI-Confirm-Box (#confirm-dialog-box // und #confirm-dialog-box-) — die hat bei uns nie Inhalt weil wir eigene // Modals nutzen. Kille sie beim Laden und beobachte weitere Einfügungen. function killDolibarrConfirmBoxes() { document.querySelectorAll('[id^="confirm-dialog-box"]').forEach(el => { const wrap = el.closest('.ui-dialog') || el; wrap.remove(); }); } new MutationObserver(killDolibarrConfirmBoxes) .observe(document.documentElement, { childList: true, subtree: true }); document.addEventListener('DOMContentLoaded', () => { killDolibarrConfirmBoxes(); init(); }); })();