bericht/js/editor.js
Eduard Wisch 06cd70d4a3
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
feat: Phase 1.4 + 1.5 — Multi-Image Grids und Bildgröße komplett
DB-Klasse:
- BerichtPage::getImages() liest llx_bericht_page_image
- BerichtPage::setSlotImage() / clearImages()
- BerichtPage::slotCountForLayout()
- BerichtPage::slotRects() berechnet Slot-Positionen für 1/2/2v/4/6
- create()/update()/fetchAllForBericht() inkludieren layout/scale/align

Endpoints (alle im Bericht-Modul):
- ajax/save_page_options.php — speichert layout, image_scale, image_align
- ajax/create_grid_page.php — erstellt Multi-Image-Seite mit gewähltem Layout
- ajax/set_slot_image.php — setzt einzelnes Bild eines Slots
- ajax/page_meta.php liefert layout/scale/align mit
- ajax/page_image.php rendert Composite-PNG (GD) für Multi-Image-Seiten

UI:
- Layout-Dropdown in 3. Toolbar-Zeile (Single/Grid 2/2v/4/6)
- Bildgröße-Dropdown (100/70/50/30%) — single-only
- Position-Dropdown (Anpassen/Zentriert/Ecken) — single-only
- 'Als Grid hinzufügen'-Buttons in der Anhänge-Liste (▭▭ ▯▯ ▦ ▦▦)
- Auto-Sync der single-only-Felder beim Layout-Wechsel

Rendering:
- bericht_render_page_to_pdf() in lib/bericht.lib.php — zentrale
  Render-Funktion für Single + Grid + PDF-Quelle + image_scale + align
- bericht_align_position() für die 6 Align-Modi
- generate_pdf + preview_pdf nutzen die gemeinsame Funktion (DRY)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
2026-04-08 22:33:44 +02:00

885 lines
38 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; }
// PDF.js worker (lokal)
if (window.pdfjsLib) {
pdfjsLib.GlobalWorkerOptions.workerSrc = '/bericht/js/lib/pdf.worker.min.js';
}
let currentPageId = null;
let currentPageEl = null;
let currentPageRotation = 0; // 0 / 90 / 180 / 270
let currentPageBuffer = null; // ArrayBuffer der aktuellen Quelle
let currentPageMime = '';
let currentPageLayout = 'single';
let currentPageScale = 1.0;
let currentPageAlign = 'fit';
let currentZoom = 1.0; // 1.0 = 100% (Container-Fit), 0.5..3.0
let fabricCanvas = null;
const pdfCanvas = document.getElementById('pdf-canvas');
let currentTool = 'select';
/* ---------- Settings-Persistenz (localStorage) ---------- */
const SETTINGS_KEY = 'bericht.editor.settings.v1';
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) return {};
return JSON.parse(raw) || {};
} catch (e) { return {}; }
}
function saveSettings(patch) {
try {
const cur = loadSettings();
const merged = Object.assign({}, cur, patch);
localStorage.setItem(SETTINGS_KEY, JSON.stringify(merged));
} catch (e) { /* localStorage full/blocked */ }
}
/* ---------- Init ---------- */
function init() {
// Gespeicherte Einstellungen anwenden — VOR Fabric-Init
const s = loadSettings();
const colorEl = document.getElementById('tool-color');
const strokeEl = document.getElementById('tool-stroke');
const ffEl = document.getElementById('tool-fontfamily');
const fsEl = document.getElementById('tool-fontsize');
const boldEl = document.getElementById('tool-bold');
const italicEl = document.getElementById('tool-italic');
if (s.color) colorEl.value = s.color;
if (s.stroke) strokeEl.value = s.stroke;
if (s.fontFamily) ffEl.value = s.fontFamily;
if (s.fontSize) fsEl.value = s.fontSize;
if (typeof s.bold !== 'undefined') boldEl.checked = !!s.bold;
if (typeof s.italic !== 'undefined') italicEl.checked = !!s.italic;
if (s.zoom) currentZoom = parseFloat(s.zoom) || 1.0;
document.getElementById('zoom-label').textContent = Math.round(currentZoom * 100) + '%';
// Fabric initialisieren (wird beim ersten Seitenrendern dimensioniert)
fabricCanvas = new fabric.Canvas('fabric-canvas', {
isDrawingMode: false,
selection: true,
});
fabricCanvas.freeDrawingBrush.color = colorEl.value;
fabricCanvas.freeDrawingBrush.width = parseInt(strokeEl.value, 10);
// Listener: speichern bei jeder Änderung
colorEl.addEventListener('change', () => saveSettings({ color: colorEl.value }));
strokeEl.addEventListener('change', () => saveSettings({ stroke: parseInt(strokeEl.value, 10) }));
ffEl.addEventListener('change', () => saveSettings({ fontFamily: ffEl.value }));
fsEl.addEventListener('change', () => saveSettings({ fontSize: parseInt(fsEl.value, 10) }));
boldEl.addEventListener('change', () => saveSettings({ bold: boldEl.checked }));
italicEl.addEventListener('change', () => saveSettings({ italic: italicEl.checked }));
// Erste Seite laden (wenn vorhanden)
const firstThumb = document.querySelector('#bericht-page-list .page-thumb');
if (firstThumb) loadPage(firstThumb);
// Alle Thumbnails parallel rendern
renderAllThumbs();
bindThumbs();
bindToolbar();
bindAttachments();
bindExtraUpload();
bindActions();
bindSortable();
// Re-Render bei Größenänderung des Container (Console öffnen, Window-Resize),
// debounced damit es nicht spamt. Nutzt ResizeObserver auf canvas-wrap.
const wrap = document.querySelector('.bericht-canvas-wrap');
if (wrap && typeof ResizeObserver !== 'undefined') {
let to = null;
let lastW = wrap.clientWidth;
const ro = new ResizeObserver(() => {
if (Math.abs(wrap.clientWidth - lastW) < 20) return;
lastW = wrap.clientWidth;
clearTimeout(to);
to = setTimeout(() => { rerenderCurrent(); }, 250);
});
ro.observe(wrap);
}
}
/* ---------- Seiten laden ---------- */
async function loadPage(thumbEl) {
// vorher: aktuelle Seite speichern
if (currentPageId) await savePageAnnotations(false);
currentPageEl = thumbEl;
currentPageId = parseInt(thumbEl.dataset.pageid, 10);
document.querySelectorAll('.page-thumb.active').forEach(e => e.classList.remove('active'));
thumbEl.classList.add('active');
const url = cfg.urls.page_image + '?pageid=' + currentPageId;
const resp = await fetch(url);
const ct = resp.headers.get('Content-Type') || '';
const buf = await resp.arrayBuffer();
currentPageBuffer = buf;
currentPageMime = ct;
currentPageRotation = 0; // wird gleich aus loadPageMeta überschrieben falls gespeichert
fabricCanvas.clear();
document.getElementById('page-note').value = '';
await loadPageMeta(); // setzt currentPageRotation falls vorhanden
await rerenderCurrent(); // rendert mit Rotation
}
/**
* Rendert die aktuelle Seite (image oder pdf) aus dem Buffer mit currentPageRotation.
*/
async function rerenderCurrent() {
if (!currentPageBuffer) return;
if (currentPageMime.includes('pdf')) {
await renderPdf(currentPageBuffer);
} else if (currentPageMime.includes('image')) {
await renderImage(currentPageBuffer, currentPageMime);
}
}
/**
* Liefert die nutzbare Breite des Canvas-Containers (minus Padding).
* Begrenzt auf 1200px, damit auch auf großen Screens nicht alles riesig wird.
*/
function getTargetCanvasWidth() {
const wrap = document.querySelector('.bericht-canvas-wrap');
if (!wrap) return 800;
const cs = getComputedStyle(wrap);
const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
const avail = wrap.clientWidth - padX - 4;
const base = Math.max(300, Math.min(1200, Math.floor(avail)));
return Math.round(base * currentZoom);
}
async function renderPdf(arrayBuffer) {
if (!window.pdfjsLib) { console.error('PDF.js nicht geladen'); return; }
// Buffer kopieren — pdf.js konsumiert den ArrayBuffer beim ersten Aufruf
const pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer.slice(0) }).promise;
const pageNum = 1;
const page = await pdfDoc.getPage(pageNum);
// Bei Rotation müssen wir die orientierte Breite messen, um auf den
// Container zu passen
const target = getTargetCanvasWidth();
const baseViewport = page.getViewport({ scale: 1, rotation: currentPageRotation });
const scale = target / baseViewport.width;
const viewport = page.getViewport({ scale: scale, rotation: currentPageRotation });
pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
const ctx = pdfCanvas.getContext('2d');
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
resizeFabricToCanvas();
}
async function renderImage(arrayBuffer, mime) {
const blob = new Blob([arrayBuffer], { type: mime });
const url = URL.createObjectURL(blob);
await new Promise((res) => {
const img = new Image();
img.onload = () => {
const target = getTargetCanvasWidth();
// Bei 90/270° werden Breite und Höhe getauscht
const rotated = (currentPageRotation === 90 || currentPageRotation === 270);
const srcW = rotated ? img.height : img.width;
const srcH = rotated ? img.width : img.height;
const ratio = target / srcW;
pdfCanvas.width = Math.round(srcW * ratio);
pdfCanvas.height = Math.round(srcH * ratio);
const ctx = pdfCanvas.getContext('2d');
ctx.clearRect(0, 0, pdfCanvas.width, pdfCanvas.height);
ctx.save();
ctx.translate(pdfCanvas.width / 2, pdfCanvas.height / 2);
ctx.rotate(currentPageRotation * Math.PI / 180);
const dw = img.width * ratio;
const dh = img.height * ratio;
ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
URL.revokeObjectURL(url);
res();
};
img.src = url;
});
resizeFabricToCanvas();
}
function resizeFabricToCanvas() {
fabricCanvas.setWidth(pdfCanvas.width);
fabricCanvas.setHeight(pdfCanvas.height);
requestAnimationFrame(() => {
// Fabric wickelt das Canvas in einen .canvas-container ein —
// DIESEN müssen wir absolut über dem PDF-Canvas positionieren,
// nicht das innere #fabric-canvas direkt.
const fcEl = document.getElementById('fabric-canvas');
const container = fcEl.parentElement && fcEl.parentElement.classList.contains('canvas-container')
? fcEl.parentElement
: fcEl;
const rect = pdfCanvas.getBoundingClientRect();
const wrap = pdfCanvas.parentElement;
const wrapRect = wrap.getBoundingClientRect();
container.style.position = 'absolute';
container.style.left = (rect.left - wrapRect.left + wrap.scrollLeft) + 'px';
container.style.top = (rect.top - wrapRect.top + wrap.scrollTop) + 'px';
container.style.width = pdfCanvas.clientWidth + 'px';
container.style.height = pdfCanvas.clientHeight + 'px';
container.style.zIndex = '10';
container.style.pointerEvents = 'auto';
});
}
async function loadPageMeta() {
try {
const r = await fetch(cfg.urls.save_annotations.replace('save_annotations', 'page_meta') + '?pageid=' + currentPageId);
if (!r.ok) return;
const data = await r.json();
if (data.fabric_json) {
fabricCanvas.loadFromJSON(data.fabric_json, () => fabricCanvas.renderAll());
}
if (data.note) document.getElementById('page-note').value = data.note;
if (typeof data.rotation !== 'undefined' && data.rotation !== null) {
currentPageRotation = parseInt(data.rotation, 10) || 0;
}
if (data.layout) currentPageLayout = data.layout;
if (data.image_scale) currentPageScale = parseFloat(data.image_scale);
if (data.image_align) currentPageAlign = data.image_align;
// Toolbar-Selects synchronisieren
const lEl = document.getElementById('page-layout');
const sEl = document.getElementById('page-imgscale');
const aEl = document.getElementById('page-imgalign');
if (lEl) lEl.value = currentPageLayout;
if (sEl) sEl.value = currentPageScale.toString();
if (aEl) aEl.value = currentPageAlign;
// Single-only Felder ein/ausblenden
document.querySelectorAll('.single-only').forEach(el => {
el.style.display = (currentPageLayout === 'single') ? '' : 'none';
});
} catch (e) { /* ok */ }
}
/* ---------- Toolbar ---------- */
function bindToolbar() {
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tool-btn.active').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTool = btn.dataset.tool;
applyTool();
});
});
document.getElementById('tool-color').addEventListener('input', e => {
fabricCanvas.freeDrawingBrush.color = e.target.value;
const sel = fabricCanvas.getActiveObject();
if (sel) {
sel.set({ stroke: e.target.value });
if (sel.type === 'i-text' || sel.type === 'text') sel.set({ fill: e.target.value });
fabricCanvas.requestRenderAll();
}
});
document.getElementById('tool-stroke').addEventListener('input', e => {
fabricCanvas.freeDrawingBrush.width = parseInt(e.target.value, 10);
const sel = fabricCanvas.getActiveObject();
if (sel) { sel.set({ strokeWidth: parseInt(e.target.value, 10) }); fabricCanvas.requestRenderAll(); }
});
document.getElementById('btn-undo').addEventListener('click', undo);
document.getElementById('btn-redo').addEventListener('click', redo);
document.getElementById('btn-delete-selected').addEventListener('click', () => {
const sel = fabricCanvas.getActiveObjects();
sel.forEach(o => fabricCanvas.remove(o));
fabricCanvas.discardActiveObject();
fabricCanvas.requestRenderAll();
});
// Seitenrotation
document.getElementById('btn-rotate-left').addEventListener('click', () => rotatePage(-90));
document.getElementById('btn-rotate-right').addEventListener('click', () => rotatePage(90));
// Zoom
document.getElementById('btn-zoom-in').addEventListener('click', () => setZoom(currentZoom + 0.25));
document.getElementById('btn-zoom-out').addEventListener('click', () => setZoom(currentZoom - 0.25));
document.getElementById('btn-zoom-reset').addEventListener('click', () => setZoom(1.0));
// Layout / Bildgröße / Align — pro Seite
const layoutEl = document.getElementById('page-layout');
const scaleEl = document.getElementById('page-imgscale');
const alignEl = document.getElementById('page-imgalign');
async function savePageOptions() {
if (!currentPageId) return;
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('pageid', currentPageId);
fd.append('layout', layoutEl.value);
fd.append('image_scale', scaleEl.value);
fd.append('image_align', alignEl.value);
await fetch(cfg.urls.save_page_options, { method: 'POST', body: fd });
}
if (layoutEl) layoutEl.addEventListener('change', async () => {
currentPageLayout = layoutEl.value;
document.querySelectorAll('.single-only').forEach(el => {
el.style.display = (currentPageLayout === 'single') ? '' : 'none';
});
await savePageOptions();
});
if (scaleEl) scaleEl.addEventListener('change', async () => {
currentPageScale = parseFloat(scaleEl.value);
await savePageOptions();
});
if (alignEl) alignEl.addEventListener('change', async () => {
currentPageAlign = alignEl.value;
await savePageOptions();
});
// Schrift-Optionen für Text-Tool / selektierte Texte
const fontFamily = document.getElementById('tool-fontfamily');
const fontSize = document.getElementById('tool-fontsize');
const boldChk = document.getElementById('tool-bold');
const italicChk = document.getElementById('tool-italic');
function applyTextProps() {
const sel = fabricCanvas.getActiveObject();
if (sel && (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox')) {
sel.set({
fontFamily: fontFamily.value,
fontSize: parseInt(fontSize.value, 10),
fontWeight: boldChk.checked ? 'bold' : 'normal',
fontStyle: italicChk.checked ? 'italic' : 'normal',
});
fabricCanvas.requestRenderAll();
}
}
fontFamily.addEventListener('change', applyTextProps);
fontSize.addEventListener('input', applyTextProps);
boldChk.addEventListener('change', applyTextProps);
italicChk.addEventListener('change', applyTextProps);
// Bei Selektion eines Text-Objekts die Toolbar-Werte synchronisieren
fabricCanvas.on('selection:created', syncTextToolbar);
fabricCanvas.on('selection:updated', syncTextToolbar);
function syncTextToolbar() {
const sel = fabricCanvas.getActiveObject();
if (!sel) return;
if (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox') {
if (sel.fontFamily) fontFamily.value = sel.fontFamily;
if (sel.fontSize) fontSize.value = sel.fontSize;
boldChk.checked = (sel.fontWeight === 'bold');
italicChk.checked = (sel.fontStyle === 'italic');
}
}
}
async function setZoom(z) {
currentZoom = Math.max(0.25, Math.min(3.0, Math.round(z * 100) / 100));
document.getElementById('zoom-label').textContent = Math.round(currentZoom * 100) + '%';
saveSettings({ zoom: currentZoom });
await rerenderCurrent();
}
async function rotatePage(deg) {
currentPageRotation = ((currentPageRotation + deg) % 360 + 360) % 360;
// Annotationen für die alte Orientierung sind im JSON noch da — wir werfen
// sie vor dem Re-Render weg, damit sie nicht falsch positioniert sind.
// (Bewusste Designentscheidung: rotieren vor dem Annotieren.)
fabricCanvas.clear();
await rerenderCurrent();
// Sofort speichern, damit Rotation persistent ist
await savePageAnnotations(false);
}
/* ---------- Shape-Drawing per Drag ---------- */
let drawState = null; // { shape, startX, startY }
function applyTool() {
fabricCanvas.isDrawingMode = (currentTool === 'draw');
fabricCanvas.selection = (currentTool === 'select');
// Cursor-Hint
fabricCanvas.defaultCursor = (currentTool === 'select') ? 'default' : 'crosshair';
fabricCanvas.off('mouse:down', shapeDown);
fabricCanvas.off('mouse:move', shapeMove);
fabricCanvas.off('mouse:up', shapeUp);
if (['rect', 'circle', 'arrow'].includes(currentTool)) {
fabricCanvas.on('mouse:down', shapeDown);
fabricCanvas.on('mouse:move', shapeMove);
fabricCanvas.on('mouse:up', shapeUp);
} else if (currentTool === 'text') {
fabricCanvas.on('mouse:down', shapeDown);
}
}
function shapeDown(opt) {
// Wenn wir ein vorhandenes Objekt anklicken: nicht zeichnen, selektieren
if (opt.target) return;
const p = fabricCanvas.getPointer(opt.e);
const color = document.getElementById('tool-color').value;
const sw = parseInt(document.getElementById('tool-stroke').value, 10);
if (currentTool === 'rect') {
const r = new fabric.Rect({
left: p.x, top: p.y, width: 1, height: 1,
fill: 'transparent', stroke: color, strokeWidth: sw,
originX: 'left', originY: 'top'
});
fabricCanvas.add(r);
drawState = { shape: r, startX: p.x, startY: p.y };
} else if (currentTool === 'circle') {
const e = new fabric.Ellipse({
left: p.x, top: p.y, rx: 1, ry: 1,
fill: 'transparent', stroke: color, strokeWidth: sw,
originX: 'left', originY: 'top'
});
fabricCanvas.add(e);
drawState = { shape: e, startX: p.x, startY: p.y };
} else if (currentTool === 'arrow') {
const a = makeArrow(p.x, p.y, p.x + 1, p.y + 1, color, sw);
fabricCanvas.add(a);
drawState = { shape: a, startX: p.x, startY: p.y, isArrow: true, color, sw };
} else if (currentTool === 'text') {
const ff = document.getElementById('tool-fontfamily').value || 'Helvetica';
const fs = parseInt(document.getElementById('tool-fontsize').value, 10) || 24;
const bold = document.getElementById('tool-bold').checked;
const ital = document.getElementById('tool-italic').checked;
const t = new fabric.IText('Text…', {
left: p.x, top: p.y,
fontFamily: ff, fontSize: fs,
fontWeight: bold ? 'bold' : 'normal',
fontStyle: ital ? 'italic' : 'normal',
fill: color
});
fabricCanvas.add(t);
fabricCanvas.setActiveObject(t);
const sb = document.querySelector('.tool-btn[data-tool="select"]');
if (sb) sb.click();
}
}
function shapeMove(opt) {
if (!drawState) return;
const p = fabricCanvas.getPointer(opt.e);
const s = drawState.shape;
if (drawState.isArrow) {
// Alten Pfeil entfernen, neuen mit aktualisierten Endpunkten zeichnen
fabricCanvas.remove(s);
const newArrow = makeArrow(drawState.startX, drawState.startY, p.x, p.y, drawState.color, drawState.sw);
fabricCanvas.add(newArrow);
drawState.shape = newArrow;
} else if (s.type === 'rect') {
const w = p.x - drawState.startX;
const h = p.y - drawState.startY;
s.set({
left: w < 0 ? p.x : drawState.startX,
top: h < 0 ? p.y : drawState.startY,
width: Math.abs(w),
height: Math.abs(h),
});
} else if (s.type === 'ellipse') {
const w = p.x - drawState.startX;
const h = p.y - drawState.startY;
s.set({
left: w < 0 ? p.x : drawState.startX,
top: h < 0 ? p.y : drawState.startY,
rx: Math.abs(w) / 2,
ry: Math.abs(h) / 2,
});
// Ellipse braucht ihre Größe anhand rx/ry
s.set({ width: Math.abs(w), height: Math.abs(h) });
}
s.setCoords();
fabricCanvas.requestRenderAll();
}
function shapeUp() {
if (drawState && drawState.shape) {
const s = drawState.shape;
// Sicherstellen dass das Objekt selektierbar/drehbar ist
s.set({ hasControls: true, hasBorders: true, lockRotation: false });
fabricCanvas.setActiveObject(s);
}
drawState = null;
// Nach dem Zeichnen automatisch auf Select wechseln
const sb = document.querySelector('.tool-btn[data-tool="select"]');
if (sb) sb.click();
}
/**
* Baut einen Pfeil als Fabric-Group: Linie + Dreieck als Spitze.
* Gruppe ist drehbar, skalierbar, verschiebbar.
*/
function makeArrow(x1, y1, x2, y2, color, sw) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const angle = Math.atan2(dy, dx);
const headLen = Math.max(12, sw * 4);
// Linie als Path damit sie in die Group passt — relative Koordinaten
const line = new fabric.Line([0, 0, len, 0], {
stroke: color, strokeWidth: sw, originX: 'left', originY: 'center',
});
const head = new fabric.Triangle({
left: len, top: 0,
width: headLen, height: headLen,
fill: color,
originX: 'center', originY: 'center',
angle: 90,
});
const grp = new fabric.Group([line, head], {
left: x1, top: y1,
originX: 'left', originY: 'center',
angle: angle * 180 / Math.PI,
hasControls: true, hasBorders: true, lockRotation: false,
});
return grp;
}
/* ---------- Undo/Redo (einfach) ---------- */
const history = [];
let histIdx = -1;
function snapshot() {
history.length = histIdx + 1;
history.push(JSON.stringify(fabricCanvas.toJSON()));
histIdx = history.length - 1;
}
function undo() {
if (histIdx <= 0) return;
histIdx--;
fabricCanvas.loadFromJSON(history[histIdx], () => fabricCanvas.renderAll());
}
function redo() {
if (histIdx >= history.length - 1) return;
histIdx++;
fabricCanvas.loadFromJSON(history[histIdx], () => fabricCanvas.renderAll());
}
setTimeout(() => {
if (fabricCanvas) {
fabricCanvas.on('object:added', snapshot);
fabricCanvas.on('object:modified', snapshot);
fabricCanvas.on('object:removed', snapshot);
}
}, 500);
function rotateCurrent(deg) {
const sel = fabricCanvas.getActiveObject();
if (sel) {
sel.rotate(((sel.angle || 0) + deg) % 360);
fabricCanvas.requestRenderAll();
}
}
/* ---------- Speichern ---------- */
async function savePageAnnotations(showMessage = true) {
if (!currentPageId) return;
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('pageid', currentPageId);
fd.append('fabric_json', JSON.stringify(fabricCanvas.toJSON()));
fd.append('note', document.getElementById('page-note').value || '');
fd.append('rotation', currentPageRotation);
const r = await fetch(cfg.urls.save_annotations, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (showMessage && data.success) toast('Seite gespeichert');
}
/* ---------- Meta-Felder Auto-Save ---------- */
async function saveMeta() {
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('berichtid', cfg.berichtid);
const titelEl = document.querySelector('input[name="titel"]');
const tplEl = document.querySelector('select[name="template_odt"]');
const fmtEl = document.getElementById('meta-format');
const oriEl = document.getElementById('meta-orientation');
if (titelEl) fd.append('titel', titelEl.value);
if (tplEl) fd.append('template_odt', tplEl.value);
if (fmtEl) fd.append('page_format', fmtEl.value);
if (oriEl) fd.append('page_orientation', oriEl.value);
await fetch(cfg.urls.save_meta, { method: 'POST', body: fd });
}
function bindMetaAutoSave() {
['input[name="titel"]', 'select[name="template_odt"]', '#meta-format', '#meta-orientation'].forEach(sel => {
const el = document.querySelector(sel);
if (!el) return;
el.addEventListener('change', saveMeta);
});
}
function bindActions() {
bindMetaAutoSave();
document.getElementById('btn-save-draft').addEventListener('click', async () => {
await saveMeta();
await savePageAnnotations(true);
});
// Vorschau-Modal
const previewBtn = document.getElementById('btn-preview');
if (previewBtn) {
previewBtn.addEventListener('click', async () => {
await savePageAnnotations(false);
const url = cfg.urls.preview_pdf + '?berichtid=' + cfg.berichtid + '&t=' + Date.now();
document.getElementById('bericht-preview-iframe').src = url;
document.getElementById('bericht-preview-modal').style.display = 'block';
});
}
const modalClose = document.getElementById('bericht-modal-close');
if (modalClose) modalClose.addEventListener('click', closePreviewModal);
document.querySelector('#bericht-preview-modal .bericht-modal-backdrop')
?.addEventListener('click', closePreviewModal);
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closePreviewModal();
});
document.getElementById('btn-finalize').addEventListener('click', async () => {
await savePageAnnotations(false);
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('berichtid', cfg.berichtid);
const r = await fetch(cfg.urls.generate_pdf, { method: 'POST', body: fd });
const data = await r.json();
if (data.success) {
toast('PDF erstellt: ' + data.filename);
setTimeout(() => location.reload(), 1500);
} else {
alert('Fehler: ' + (data.error || 'unbekannt'));
}
});
}
/* ---------- Anhänge & Thumbs ---------- */
function bindAttachments() {
const btn = document.getElementById('btn-add-selected');
if (btn) {
btn.addEventListener('click', async () => {
const checks = document.querySelectorAll('.att-check:checked');
for (const c of checks) {
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('berichtid', cfg.berichtid);
fd.append('relpath', c.dataset.relpath);
fd.append('mime', c.dataset.mime);
await fetch(cfg.urls.add_attachment, { method: 'POST', body: fd });
c.checked = false;
}
location.reload();
});
}
// Grid-Buttons: ausgewählte Bilder als eine Multi-Image-Seite hinzufügen
document.querySelectorAll('.btn-add-grid').forEach(b => {
b.addEventListener('click', async () => {
const layout = b.dataset.layout;
const checks = document.querySelectorAll('.att-check:checked');
if (!checks.length) {
alert('Bitte zuerst Bilder ankreuzen');
return;
}
const slotCount = { grid_2: 2, grid_2v: 2, grid_4: 4, grid_6: 6 }[layout] || 4;
const relpaths = Array.from(checks)
.filter(c => c.dataset.mime.startsWith('image'))
.slice(0, slotCount)
.map(c => c.dataset.relpath);
if (!relpaths.length) {
alert('Bitte mindestens ein Bild ankreuzen (PDFs sind in Grids nicht unterstützt)');
return;
}
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('berichtid', cfg.berichtid);
fd.append('layout', layout);
fd.append('relpaths', JSON.stringify(relpaths));
const r = await fetch(cfg.urls.create_grid_page, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (data.success) location.reload();
else alert('Fehler: ' + (data.error || 'unbekannt'));
});
});
// Lösch-Buttons in der Anhänge-Liste
document.querySelectorAll('.att-delete').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const rel = btn.dataset.relpath;
const ref = btn.dataset.sourceRef || '';
const name = btn.parentElement.querySelector('.att-name')?.textContent || rel;
if (!confirm('Datei "' + name + '" aus ' + (ref ? ref : 'dem Anhang') + ' wirklich löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden.')) return;
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('relpath', rel);
const r = await fetch(cfg.urls.delete_attachment, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (data.success) {
btn.parentElement.remove();
toast('Datei gelöscht');
} else {
alert('Löschen fehlgeschlagen: ' + (data.error || 'unbekannt'));
}
});
});
}
function bindExtraUpload() {
const inp = document.getElementById('bericht-extra-upload');
if (!inp) return;
inp.addEventListener('change', async () => {
if (!inp.files.length) return;
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('berichtid', cfg.berichtid);
fd.append('file', inp.files[0]);
const r = await fetch(cfg.urls.upload_extra, { method: 'POST', body: fd });
const data = await r.json();
if (data.success) location.reload();
else alert('Upload fehlgeschlagen: ' + (data.error || ''));
});
}
function bindThumbs() {
document.querySelectorAll('.page-thumb').forEach(t => {
t.addEventListener('click', e => {
if (e.target.closest('.thumb-del')) return;
loadPage(t);
});
const del = t.querySelector('.thumb-del');
if (del) del.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(cfg.lang.confirm_del)) return;
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('pageid', t.dataset.pageid);
await fetch(cfg.urls.delete_page, { method: 'POST', body: fd });
location.reload();
});
});
// Hell/Dunkel-Toggle
const tg = document.getElementById('btn-toggle-thumb-bg');
if (tg) tg.addEventListener('click', () => {
const list = document.getElementById('bericht-page-list');
list.classList.toggle('paper-light');
list.classList.toggle('paper-dark');
});
}
/**
* Rendert alle Thumbnails in der rechten Seitenleiste.
* Holt jedes Bild über page_image.php und zeichnet es klein in das Thumb-Canvas.
* PDFs werden mit PDF.js gerendert.
*/
async function renderAllThumbs() {
const thumbs = document.querySelectorAll('.page-thumb');
for (const t of thumbs) {
const pageid = t.dataset.pageid;
const canvas = t.querySelector('.thumb-canvas');
if (!canvas || !pageid) continue;
try {
const r = await fetch(cfg.urls.page_image + '?pageid=' + pageid);
const ct = r.headers.get('Content-Type') || '';
const buf = await r.arrayBuffer();
if (ct.includes('pdf')) {
await renderThumbPdf(canvas, buf);
} else if (ct.includes('image')) {
await renderThumbImage(canvas, buf, ct);
}
} catch (e) { /* skip */ }
}
}
async function renderThumbImage(canvas, buf, mime) {
return new Promise(res => {
const blob = new Blob([buf], { type: mime });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const maxSide = 200;
const ratio = Math.min(maxSide / img.width, maxSide / img.height);
canvas.width = Math.round(img.width * ratio);
canvas.height = Math.round(img.height * ratio);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
res();
};
img.onerror = () => { URL.revokeObjectURL(url); res(); };
img.src = url;
});
}
async function renderThumbPdf(canvas, buf) {
if (!window.pdfjsLib) return;
try {
const doc = await pdfjsLib.getDocument({ data: buf.slice(0) }).promise;
const page = await doc.getPage(1);
const base = page.getViewport({ scale: 1 });
const scale = 200 / base.width;
const vp = page.getViewport({ scale: scale });
canvas.width = vp.width;
canvas.height = vp.height;
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
} catch (e) { /* skip */ }
}
function bindSortable() {
const list = document.getElementById('bericht-page-list');
if (!list || !window.Sortable) return;
Sortable.create(list, {
animation: 150,
onEnd: async () => {
const ids = Array.from(list.querySelectorAll('.page-thumb')).map(t => t.dataset.pageid);
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('order', JSON.stringify(ids));
await fetch(cfg.urls.reorder_pages, { method: 'POST', body: fd });
}
});
}
function closePreviewModal() {
const m = document.getElementById('bericht-preview-modal');
if (m) m.style.display = 'none';
const ifr = document.getElementById('bericht-preview-iframe');
if (ifr) ifr.src = 'about:blank';
}
/* ---------- Helpers ---------- */
function toast(msg) {
const t = document.createElement('div');
t.className = 'bericht-toast';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2000);
}
document.addEventListener('DOMContentLoaded', init);
})();