bericht/js/editor.js
Eduard Wisch 4102ebfe5b
All checks were successful
Deploy bericht / deploy (push) Successful in 13s
Editor-Ueberarbeitung 1.5.0: Anhaenge-Kacheln, echte Seitengeometrie, Arbeiten ohne Reload [deploy]
Anhaenge-Spalte: Kachelraster mit Server-Thumbnails statt Dateinamen-Liste (Bilder per GD,
PDFs per PDF.js), Grossansicht per Klick, Auswahlreihenfolge sichtbar, Sortierung/Filter
(Aufnahmezeit per eigenem EXIF-Parser, da im Prod-Container ohne exif-Modul), ziehbare
Spaltenbreite, Kachelgroesse, Drag & Drop auf Seitenliste/Arbeitsflaeche/Raster-Plaetze.

Arbeitstisch zeigt jetzt Kopf-, Inhalts- und Fussbereich passend zur echten Seitengeometrie
(bericht_page_geometry()) - Editor und PDF-Erzeugung rechnen mit denselben Werten, das PDF
setzt die Arbeitsflaeche 1:1 statt sie ein zweites Mal einzupassen.

Neu: Bild einer Seite austauschen (auch einzelne Raster-Plaetze), Seiten mehrfach markieren
und sammeln loeschen/duplizieren, Autosave mit Verlassen-Warnung, Tastenkuerzel, kein
location.reload() mehr nach Hinzufuegen/Loeschen/Hochladen (ajax/fragments.php).

Nebenbei behoben: dol_dir_list() ohne mode=1 (Dateigroesse leer), PDF.js-Worker-Pfad ohne
/custom-Praefix, PDFs liefen als Download statt inline, Groessen-Select blieb leer,
Mehrfach-Upload nahm nur die erste Datei, Race-Condition bei schnellem Seitenwechsel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:33:20 +02:00

2825 lines
122 KiB
JavaScript

/*
* 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 = '<span class="slot-badge">' + (labels ? labels[i] : (i + 1)) + '</span>';
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 = `
<div class="bericht-modal" id="sig-verify-modal" style="display:block;">
<div class="bericht-modal-backdrop"></div>
<div class="bericht-modal-content" style="max-width:600px;left:50%;transform:translateX(-50%);height:auto;bottom:auto;top:10vh;">
<div class="bericht-modal-header" style="background:${bg};color:#fff;">
<h3 style="color:#fff;">${icon} ${escapeHtml(title)}</h3>
<button type="button" id="sig-verify-close" title="Schließen">✕</button>
</div>
<div class="bericht-modal-body" style="padding:20px;overflow-y:auto;">
${!verified ? `<p style="color:#d9534f;font-weight:600;">${escapeHtml(data.reason || '')}</p>` : ''}
<table style="width:100%;font-size:13px;">
<tr><td style="opacity:0.7;padding:4px 0;">Unterzeichner:</td><td><strong>${escapeHtml(m.signer_name || '—')}</strong></td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Kunde:</td><td>${escapeHtml(m.kunde || '')}</td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Parent:</td><td>${escapeHtml(m.parent_ref || '')}</td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Signiert am:</td><td>${escapeHtml(m.signed_at || '')}</td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Erfasst durch:</td><td>${escapeHtml(m.user_login || '')}</td></tr>
${m.gps_lat ? `<tr><td style="opacity:0.7;padding:4px 0;">GPS:</td><td><a href="https://www.openstreetmap.org/?mlat=${m.gps_lat}&mlon=${m.gps_lon}&zoom=18" target="_blank">${m.gps_lat}, ${m.gps_lon}</a></td></tr>` : ''}
<tr><td style="opacity:0.7;padding:4px 0;">IP:</td><td><code>${escapeHtml(m.remote_ip || '')}</code></td></tr>
<tr><td style="opacity:0.7;padding:4px 0;vertical-align:top;">Gespeicherter Hash:</td><td><code style="font-size:10px;word-break:break-all;">${escapeHtml(data.stored_hash || '')}</code></td></tr>
<tr><td style="opacity:0.7;padding:4px 0;vertical-align:top;">Aktueller Hash:</td><td><code style="font-size:10px;word-break:break-all;">${escapeHtml(data.current_hash || '')}</code></td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Seiten bei Signatur:</td><td>${data.expected_page_count}</td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Seiten jetzt vor Signatur:</td><td>${data.current_page_count}</td></tr>
</table>
</div>
</div>
</div>
`;
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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-<btnid>) — 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(); });
})();