All checks were successful
Deploy bericht / deploy (push) Successful in 1s
Eddys Browser blockiert alert/confirm/prompt, deshalb sind die Dialoge unsichtbar. Komplett umgestellt: - js/editor.js: dolAlert/dolConfirm/dolPrompt als Promise-basierte Modals mit passendem Dark-Theme-Styling. Globaler click-Handler für data-dolconfirm an Links/Buttons. - Alle alert()/confirm()/prompt() in editor.js ersetzt (Template speichern, Finalize, Upload, Löschen, Signatur-Verify usw.). - bericht_card.php + admin/setup.php: onclick="return confirm(...)" durch data-dolconfirm="..." ersetzt. - btn-finalize: butActionConfirm → butAction (Dolibarr erzeugt sonst automatisch einen leeren jQuery-UI-Confirm-Dialog #confirm-dialog-box-btn-finalize, unser eigener Handler macht das jetzt). - css/bericht.css: Styling für .bericht-dolmodal*.
1357 lines
60 KiB
JavaScript
1357 lines
60 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) {
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = '/bericht/js/lib/pdf.worker.min.js';
|
|
}
|
|
|
|
let currentPageId = null;
|
|
let currentPageEl = null;
|
|
let currentPageRotation = 0; // 0 / 90 / 180 / 270
|
|
let currentPageBuffer = null; // ArrayBuffer der aktuellen Quelle
|
|
let currentPageMime = '';
|
|
let currentPageLayout = 'single';
|
|
let currentPageScale = 1.0;
|
|
let currentPageAlign = 'fit';
|
|
let currentZoom = 1.0; // 1.0 = 100% (Container-Fit), 0.5..3.0
|
|
let fabricCanvas = null;
|
|
const pdfCanvas = document.getElementById('pdf-canvas');
|
|
let currentTool = 'select';
|
|
|
|
/* ---------- Settings-Persistenz (localStorage) ---------- */
|
|
const SETTINGS_KEY = 'bericht.editor.settings.v1';
|
|
function loadSettings() {
|
|
try {
|
|
const raw = localStorage.getItem(SETTINGS_KEY);
|
|
if (!raw) return {};
|
|
return JSON.parse(raw) || {};
|
|
} catch (e) { return {}; }
|
|
}
|
|
function saveSettings(patch) {
|
|
try {
|
|
const cur = loadSettings();
|
|
const merged = Object.assign({}, cur, patch);
|
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(merged));
|
|
} catch (e) { /* localStorage full/blocked */ }
|
|
}
|
|
|
|
/* ---------- Init ---------- */
|
|
function init() {
|
|
// Leer-Zustand nur wenn GAR keine Seite existiert
|
|
const wrapEmpty = document.querySelector('.bericht-canvas-wrap');
|
|
const hasAnyThumb = !!document.querySelector('#bericht-page-list .page-thumb');
|
|
if (wrapEmpty && !hasAnyThumb) wrapEmpty.classList.add('empty');
|
|
|
|
// Gespeicherte Einstellungen anwenden — VOR Fabric-Init
|
|
const s = loadSettings();
|
|
const colorEl = document.getElementById('tool-color');
|
|
const strokeEl = document.getElementById('tool-stroke');
|
|
const ffEl = document.getElementById('tool-fontfamily');
|
|
const fsEl = document.getElementById('tool-fontsize');
|
|
const boldEl = document.getElementById('tool-bold');
|
|
const italicEl = document.getElementById('tool-italic');
|
|
|
|
if (s.color) colorEl.value = s.color;
|
|
if (s.stroke) strokeEl.value = s.stroke;
|
|
if (s.fontFamily) ffEl.value = s.fontFamily;
|
|
if (s.fontSize) fsEl.value = s.fontSize;
|
|
if (typeof s.bold !== 'undefined') boldEl.checked = !!s.bold;
|
|
if (typeof s.italic !== 'undefined') italicEl.checked = !!s.italic;
|
|
if (s.zoom) currentZoom = parseFloat(s.zoom) || 1.0;
|
|
document.getElementById('zoom-label').textContent = Math.round(currentZoom * 100) + '%';
|
|
|
|
// Fabric initialisieren (wird beim ersten Seitenrendern dimensioniert)
|
|
fabricCanvas = new fabric.Canvas('fabric-canvas', {
|
|
isDrawingMode: false,
|
|
selection: true,
|
|
});
|
|
fabricCanvas.freeDrawingBrush.color = colorEl.value;
|
|
fabricCanvas.freeDrawingBrush.width = parseInt(strokeEl.value, 10);
|
|
|
|
// Listener: speichern bei jeder Änderung
|
|
colorEl.addEventListener('change', () => saveSettings({ color: colorEl.value }));
|
|
strokeEl.addEventListener('change', () => saveSettings({ stroke: parseInt(strokeEl.value, 10) }));
|
|
ffEl.addEventListener('change', () => saveSettings({ fontFamily: ffEl.value }));
|
|
fsEl.addEventListener('change', () => saveSettings({ fontSize: parseInt(fsEl.value, 10) }));
|
|
boldEl.addEventListener('change', () => saveSettings({ bold: boldEl.checked }));
|
|
italicEl.addEventListener('change', () => saveSettings({ italic: italicEl.checked }));
|
|
|
|
// Erste Seite laden (wenn vorhanden)
|
|
const firstThumb = document.querySelector('#bericht-page-list .page-thumb');
|
|
if (firstThumb) loadPage(firstThumb);
|
|
|
|
// Alle Thumbnails parallel rendern
|
|
renderAllThumbs();
|
|
|
|
bindThumbs();
|
|
bindToolbar();
|
|
bindAttachments();
|
|
bindExtraUpload();
|
|
bindActions();
|
|
bindSortable();
|
|
|
|
// Re-Render bei Größenänderung des Container (Console öffnen, Window-Resize),
|
|
// debounced damit es nicht spamt. Nutzt ResizeObserver auf canvas-wrap.
|
|
const wrap = document.querySelector('.bericht-canvas-wrap');
|
|
if (wrap && typeof ResizeObserver !== 'undefined') {
|
|
let to = null;
|
|
let lastW = wrap.clientWidth;
|
|
const ro = new ResizeObserver(() => {
|
|
if (Math.abs(wrap.clientWidth - lastW) < 20) return;
|
|
lastW = wrap.clientWidth;
|
|
clearTimeout(to);
|
|
to = setTimeout(() => { rerenderCurrent(); }, 250);
|
|
});
|
|
ro.observe(wrap);
|
|
}
|
|
}
|
|
|
|
/* ---------- Seiten laden ---------- */
|
|
async function loadPage(thumbEl) {
|
|
// vorher: aktuelle Seite speichern
|
|
if (currentPageId) await savePageAnnotations(false);
|
|
|
|
currentPageEl = thumbEl;
|
|
currentPageId = parseInt(thumbEl.dataset.pageid, 10);
|
|
|
|
document.querySelectorAll('.page-thumb.active').forEach(e => e.classList.remove('active'));
|
|
thumbEl.classList.add('active');
|
|
|
|
const url = cfg.urls.page_image + '?pageid=' + currentPageId;
|
|
const resp = await fetch(url);
|
|
const ct = resp.headers.get('Content-Type') || '';
|
|
const buf = await resp.arrayBuffer();
|
|
|
|
currentPageBuffer = buf;
|
|
currentPageMime = ct;
|
|
currentPageRotation = 0;
|
|
|
|
// Empty-Status beenden — Canvas bekommt jetzt echten Inhalt
|
|
const wrapL = document.querySelector('.bericht-canvas-wrap');
|
|
if (wrapL) wrapL.classList.remove('empty'); // wird gleich aus loadPageMeta überschrieben falls gespeichert
|
|
|
|
fabricCanvas.clear();
|
|
setNoteValue('');
|
|
|
|
await loadPageMeta(); // setzt currentPageRotation + ggf. loadedFabricJson
|
|
|
|
// Canvas-Dimensionen IMMER auf A4 setzen
|
|
const target = getTargetCanvasWidth();
|
|
const pageAspect = 1 / 1.414;
|
|
const isLandscape = currentPageRotation === 90 || currentPageRotation === 270;
|
|
pdfCanvas.width = target;
|
|
pdfCanvas.height = isLandscape ? Math.round(target * pageAspect) : Math.round(target / pageAspect);
|
|
const pctx = pdfCanvas.getContext('2d');
|
|
pctx.fillStyle = '#ffffff';
|
|
pctx.fillRect(0, 0, pdfCanvas.width, pdfCanvas.height);
|
|
resizeFabricToCanvas();
|
|
|
|
// Quellbild IMMER frisch laden — gespeicherte bgImage-Objekte haben eine
|
|
// blob:-URL, die nach Reload ungültig ist.
|
|
await rerenderCurrent();
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rendert die aktuelle Seite (image oder pdf) aus dem Buffer mit currentPageRotation.
|
|
*/
|
|
async function rerenderCurrent() {
|
|
if (!currentPageBuffer) return;
|
|
if (currentPageMime.includes('pdf')) {
|
|
await renderPdf(currentPageBuffer);
|
|
} else if (currentPageMime.includes('image')) {
|
|
await renderImage(currentPageBuffer, currentPageMime);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Liefert die nutzbare Breite des Canvas-Containers (minus Padding).
|
|
* Begrenzt auf 1200px, damit auch auf großen Screens nicht alles riesig wird.
|
|
*/
|
|
function getTargetCanvasWidth() {
|
|
const wrap = document.querySelector('.bericht-canvas-wrap');
|
|
if (!wrap) return 800;
|
|
const cs = getComputedStyle(wrap);
|
|
const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
|
|
const avail = wrap.clientWidth - padX - 4;
|
|
const base = Math.max(300, Math.min(1200, Math.floor(avail)));
|
|
return Math.round(base * currentZoom);
|
|
}
|
|
|
|
async function renderPdf(arrayBuffer) {
|
|
if (!window.pdfjsLib) { console.error('PDF.js nicht geladen'); return; }
|
|
// Buffer kopieren — pdf.js konsumiert den ArrayBuffer beim ersten Aufruf
|
|
const pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer.slice(0) }).promise;
|
|
const pageNum = 1;
|
|
const page = await pdfDoc.getPage(pageNum);
|
|
// Bei Rotation müssen wir die orientierte Breite messen, um auf den
|
|
// Container zu passen
|
|
const target = getTargetCanvasWidth();
|
|
const baseViewport = page.getViewport({ scale: 1, rotation: currentPageRotation });
|
|
const scale = target / baseViewport.width;
|
|
const viewport = page.getViewport({ scale: scale, rotation: currentPageRotation });
|
|
pdfCanvas.width = viewport.width;
|
|
pdfCanvas.height = viewport.height;
|
|
const ctx = pdfCanvas.getContext('2d');
|
|
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
|
|
resizeFabricToCanvas();
|
|
}
|
|
|
|
async function renderImage(arrayBuffer, mime) {
|
|
// Phase 6: Das Quell-Bild kommt als ZIEHBARES Fabric-Image-Objekt in
|
|
// den Canvas, nicht mehr als festes Hintergrund-Bild. User kann es
|
|
// verschieben, skalieren, rotieren wie jedes andere Fabric-Objekt.
|
|
|
|
const target = getTargetCanvasWidth();
|
|
// A4-Hochformat als Canvas-Grundfläche
|
|
const pageAspect = 1 / 1.414; // Breite:Höhe
|
|
const isLandscape = currentPageRotation === 90 || currentPageRotation === 270;
|
|
const canvasW = target;
|
|
const canvasH = isLandscape ? Math.round(target * pageAspect) : Math.round(target / pageAspect);
|
|
|
|
// pdfCanvas wird nur noch weiße Fläche (für Fabric-Overlay-Positionierung)
|
|
pdfCanvas.width = canvasW;
|
|
pdfCanvas.height = canvasH;
|
|
const ctx = pdfCanvas.getContext('2d');
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
|
|
resizeFabricToCanvas();
|
|
|
|
// Bild als Fabric-Image laden
|
|
const blob = new Blob([arrayBuffer], { type: mime });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
// 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);
|
|
|
|
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;
|
|
if (sEl) sEl.value = currentPageScale.toString();
|
|
if (aEl) aEl.value = currentPageAlign;
|
|
document.querySelectorAll('.single-only').forEach(el => {
|
|
el.style.display = (currentPageLayout === 'single') ? '' : 'none';
|
|
});
|
|
} catch (e) { /* ok */ }
|
|
}
|
|
|
|
/* ---------- Toolbar ---------- */
|
|
function bindToolbar() {
|
|
document.querySelectorAll('.tool-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.tool-btn.active').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
currentTool = btn.dataset.tool;
|
|
applyTool();
|
|
});
|
|
});
|
|
|
|
document.getElementById('tool-color').addEventListener('input', e => {
|
|
fabricCanvas.freeDrawingBrush.color = e.target.value;
|
|
const sel = fabricCanvas.getActiveObject();
|
|
if (sel) {
|
|
sel.set({ stroke: e.target.value });
|
|
if (sel.type === 'i-text' || sel.type === 'text') sel.set({ fill: e.target.value });
|
|
fabricCanvas.requestRenderAll();
|
|
}
|
|
});
|
|
document.getElementById('tool-stroke').addEventListener('input', e => {
|
|
fabricCanvas.freeDrawingBrush.width = parseInt(e.target.value, 10);
|
|
const sel = fabricCanvas.getActiveObject();
|
|
if (sel) { sel.set({ strokeWidth: parseInt(e.target.value, 10) }); fabricCanvas.requestRenderAll(); }
|
|
});
|
|
|
|
document.getElementById('btn-undo').addEventListener('click', undo);
|
|
document.getElementById('btn-redo').addEventListener('click', redo);
|
|
document.getElementById('btn-delete-selected').addEventListener('click', () => {
|
|
const sel = fabricCanvas.getActiveObjects();
|
|
sel.forEach(o => fabricCanvas.remove(o));
|
|
fabricCanvas.discardActiveObject();
|
|
fabricCanvas.requestRenderAll();
|
|
});
|
|
// Seitenrotation
|
|
document.getElementById('btn-rotate-left').addEventListener('click', () => rotatePage(-90));
|
|
document.getElementById('btn-rotate-right').addEventListener('click', () => rotatePage(90));
|
|
|
|
// Zoom
|
|
document.getElementById('btn-zoom-in').addEventListener('click', () => setZoom(currentZoom + 0.25));
|
|
document.getElementById('btn-zoom-out').addEventListener('click', () => setZoom(currentZoom - 0.25));
|
|
document.getElementById('btn-zoom-reset').addEventListener('click', () => setZoom(1.0));
|
|
|
|
// Layout / Bildgröße / Align — pro Seite
|
|
const layoutEl = document.getElementById('page-layout');
|
|
const scaleEl = document.getElementById('page-imgscale');
|
|
const alignEl = document.getElementById('page-imgalign');
|
|
async function savePageOptions() {
|
|
if (!currentPageId) return;
|
|
const fd = new FormData();
|
|
fd.append('token', cfg.token);
|
|
fd.append('pageid', currentPageId);
|
|
fd.append('layout', layoutEl.value);
|
|
fd.append('image_scale', scaleEl.value);
|
|
fd.append('image_align', alignEl.value);
|
|
await fetch(cfg.urls.save_page_options, { method: 'POST', body: fd });
|
|
}
|
|
if (layoutEl) layoutEl.addEventListener('change', async () => {
|
|
currentPageLayout = layoutEl.value;
|
|
document.querySelectorAll('.single-only').forEach(el => {
|
|
el.style.display = (currentPageLayout === 'single') ? '' : 'none';
|
|
});
|
|
await savePageOptions();
|
|
});
|
|
if (scaleEl) scaleEl.addEventListener('change', async () => {
|
|
currentPageScale = parseFloat(scaleEl.value);
|
|
await savePageOptions();
|
|
});
|
|
if (alignEl) alignEl.addEventListener('change', async () => {
|
|
currentPageAlign = alignEl.value;
|
|
await savePageOptions();
|
|
});
|
|
|
|
// Schrift-Optionen für Text-Tool / selektierte Texte
|
|
const fontFamily = document.getElementById('tool-fontfamily');
|
|
const fontSize = document.getElementById('tool-fontsize');
|
|
const boldChk = document.getElementById('tool-bold');
|
|
const italicChk = document.getElementById('tool-italic');
|
|
|
|
function applyTextProps() {
|
|
const sel = fabricCanvas.getActiveObject();
|
|
if (sel && (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox')) {
|
|
sel.set({
|
|
fontFamily: fontFamily.value,
|
|
fontSize: parseInt(fontSize.value, 10),
|
|
fontWeight: boldChk.checked ? 'bold' : 'normal',
|
|
fontStyle: italicChk.checked ? 'italic' : 'normal',
|
|
});
|
|
fabricCanvas.requestRenderAll();
|
|
}
|
|
}
|
|
fontFamily.addEventListener('change', applyTextProps);
|
|
fontSize.addEventListener('input', applyTextProps);
|
|
boldChk.addEventListener('change', applyTextProps);
|
|
italicChk.addEventListener('change', applyTextProps);
|
|
|
|
// Text-Hintergrund
|
|
const bgEl = document.getElementById('tool-bgcolor');
|
|
const bgOff = document.getElementById('tool-bg-off');
|
|
if (bgEl) {
|
|
bgEl.dataset.active = 'on';
|
|
bgEl.addEventListener('input', () => {
|
|
bgEl.dataset.active = 'on';
|
|
const sel = fabricCanvas.getActiveObject();
|
|
if (sel && (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox')) {
|
|
sel.set({ textBackgroundColor: bgEl.value, padding: 6 });
|
|
fabricCanvas.requestRenderAll();
|
|
}
|
|
});
|
|
}
|
|
if (bgOff) {
|
|
bgOff.addEventListener('click', () => {
|
|
if (bgEl) bgEl.dataset.active = 'off';
|
|
const sel = fabricCanvas.getActiveObject();
|
|
if (sel && (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox')) {
|
|
sel.set({ textBackgroundColor: '', padding: 0 });
|
|
fabricCanvas.requestRenderAll();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Bei Selektion eines Text-Objekts die Toolbar-Werte synchronisieren
|
|
fabricCanvas.on('selection:created', syncTextToolbar);
|
|
fabricCanvas.on('selection:updated', syncTextToolbar);
|
|
function syncTextToolbar() {
|
|
const sel = fabricCanvas.getActiveObject();
|
|
if (!sel) return;
|
|
if (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox') {
|
|
if (sel.fontFamily) fontFamily.value = sel.fontFamily;
|
|
if (sel.fontSize) fontSize.value = sel.fontSize;
|
|
boldChk.checked = (sel.fontWeight === 'bold');
|
|
italicChk.checked = (sel.fontStyle === 'italic');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function setZoom(z) {
|
|
currentZoom = Math.max(0.25, Math.min(3.0, Math.round(z * 100) / 100));
|
|
document.getElementById('zoom-label').textContent = Math.round(currentZoom * 100) + '%';
|
|
saveSettings({ zoom: currentZoom });
|
|
await rerenderCurrent();
|
|
}
|
|
|
|
async function rotatePage(deg) {
|
|
currentPageRotation = ((currentPageRotation + deg) % 360 + 360) % 360;
|
|
// Annotationen für die alte Orientierung sind im JSON noch da — wir werfen
|
|
// sie vor dem Re-Render weg, damit sie nicht falsch positioniert sind.
|
|
// (Bewusste Designentscheidung: rotieren vor dem Annotieren.)
|
|
fabricCanvas.clear();
|
|
await rerenderCurrent();
|
|
// Sofort speichern, damit Rotation persistent ist
|
|
await savePageAnnotations(false);
|
|
}
|
|
|
|
/* ---------- Shape-Drawing per Drag ---------- */
|
|
let drawState = null; // { shape, startX, startY }
|
|
|
|
function applyTool() {
|
|
fabricCanvas.isDrawingMode = (currentTool === 'draw');
|
|
fabricCanvas.selection = (currentTool === 'select');
|
|
// Cursor-Hint
|
|
fabricCanvas.defaultCursor = (currentTool === 'select') ? 'default' : 'crosshair';
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
/* ---------- 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 (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);
|
|
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 () => {
|
|
const checks = document.querySelectorAll('.att-check:checked');
|
|
if (!checks.length) {
|
|
dolAlert('Bitte zuerst Bilder ankreuzen');
|
|
return;
|
|
}
|
|
const layout = layoutSel ? layoutSel.value : 'single';
|
|
|
|
if (layout === 'single') {
|
|
// Wie bisher: jedes Bild als eigene Seite
|
|
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();
|
|
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 imageChecks = Array.from(checks).filter(c => c.dataset.mime.startsWith('image'));
|
|
if (!imageChecks.length) {
|
|
dolAlert('Bitte mindestens ein Bild ankreuzen (PDFs nicht in Grids unterstützt)');
|
|
return;
|
|
}
|
|
// In Gruppen à slotCount aufteilen
|
|
for (let i = 0; i < imageChecks.length; i += slotCount) {
|
|
const group = imageChecks.slice(i, i + slotCount).map(c => c.dataset.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;
|
|
}
|
|
}
|
|
location.reload();
|
|
});
|
|
}
|
|
|
|
// Lösch-Buttons in der Anhänge-Liste
|
|
document.querySelectorAll('.att-delete').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const rel = btn.dataset.relpath;
|
|
const ref = btn.dataset.sourceRef || '';
|
|
const name = btn.parentElement.querySelector('.att-name')?.textContent || rel;
|
|
if (!(await dolConfirm('Datei "' + name + '" aus ' + (ref ? ref : 'dem Anhang') + ' wirklich löschen? Diese 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 {
|
|
dolAlert('Löschen fehlgeschlagen: ' + (data.error || 'unbekannt'));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function bindExtraUpload() {
|
|
const inp = document.getElementById('bericht-extra-upload');
|
|
if (inp) {
|
|
inp.addEventListener('change', async () => {
|
|
if (!inp.files.length) return;
|
|
const fd = new FormData();
|
|
fd.append('token', cfg.token);
|
|
fd.append('berichtid', cfg.berichtid);
|
|
fd.append('file', inp.files[0]);
|
|
const r = await fetch(cfg.urls.upload_extra, { method: 'POST', body: fd });
|
|
const data = await r.json();
|
|
if (data.success) location.reload();
|
|
else dolAlert('Upload fehlgeschlagen: ' + (data.error || ''));
|
|
});
|
|
}
|
|
|
|
// QR-Modal für Mobile-Upload
|
|
const qrBtn = document.getElementById('btn-show-qr');
|
|
if (qrBtn) qrBtn.addEventListener('click', openQrModal);
|
|
const qrClose = document.getElementById('bericht-qr-close');
|
|
if (qrClose) qrClose.addEventListener('click', closeQrModal);
|
|
document.querySelector('#bericht-qr-modal .bericht-modal-backdrop')
|
|
?.addEventListener('click', closeQrModal);
|
|
}
|
|
|
|
let qrPollInterval = null;
|
|
let qrLastPageCount = null;
|
|
|
|
async function openQrModal() {
|
|
const fd = new FormData();
|
|
fd.append('token', cfg.token);
|
|
fd.append('berichtid', cfg.berichtid);
|
|
const r = await fetch(cfg.urls.create_upload_token, { method: 'POST', body: fd });
|
|
const data = await r.json();
|
|
if (!data.success) {
|
|
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 nach neuen Pages
|
|
qrLastPageCount = document.querySelectorAll('.page-thumb').length;
|
|
if (qrPollInterval) clearInterval(qrPollInterval);
|
|
qrPollInterval = setInterval(async () => {
|
|
try {
|
|
const r = await fetch(cfg.urls.list_pages + '?berichtid=' + cfg.berichtid);
|
|
const d = await r.json();
|
|
if (d.success && d.count !== qrLastPageCount) {
|
|
document.querySelector('.qr-status').textContent =
|
|
'✓ ' + (d.count - qrLastPageCount) + ' neue(s) Foto(s) hochgeladen — Seite wird neu geladen…';
|
|
setTimeout(() => location.reload(), 1500);
|
|
clearInterval(qrPollInterval);
|
|
}
|
|
} catch (e) {}
|
|
}, 5000);
|
|
}
|
|
|
|
function closeQrModal() {
|
|
const m = document.getElementById('bericht-qr-modal');
|
|
if (m) m.style.display = 'none';
|
|
if (qrPollInterval) { clearInterval(qrPollInterval); qrPollInterval = null; }
|
|
}
|
|
|
|
function bindThumbs() {
|
|
document.querySelectorAll('.page-thumb').forEach(t => {
|
|
t.addEventListener('click', e => {
|
|
if (e.target.closest('.thumb-del')) return;
|
|
loadPage(t);
|
|
});
|
|
const del = t.querySelector('.thumb-del');
|
|
if (del) del.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
if (!(await dolConfirm(cfg.lang.confirm_del))) return;
|
|
const fd = new FormData();
|
|
fd.append('token', cfg.token);
|
|
fd.append('pageid', t.dataset.pageid);
|
|
await fetch(cfg.urls.delete_page, { method: 'POST', body: fd });
|
|
location.reload();
|
|
});
|
|
});
|
|
|
|
// Hell/Dunkel-Toggle
|
|
const tg = document.getElementById('btn-toggle-thumb-bg');
|
|
if (tg) tg.addEventListener('click', () => {
|
|
const list = document.getElementById('bericht-page-list');
|
|
list.classList.toggle('paper-light');
|
|
list.classList.toggle('paper-dark');
|
|
});
|
|
|
|
// Unterschriften-Verifikation
|
|
document.querySelectorAll('.thumb-verify').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const pageid = btn.dataset.pageid;
|
|
btn.textContent = '⏳';
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('token', cfg.token);
|
|
fd.append('pageid', pageid);
|
|
const r = await fetch(cfg.urls.verify_signature, { method: 'POST', body: fd });
|
|
const data = await r.json();
|
|
btn.textContent = '🔍';
|
|
if (!data.success) {
|
|
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
}
|
|
|
|
/**
|
|
* Rendert alle Thumbnails in der rechten Seitenleiste.
|
|
* Holt jedes Bild über page_image.php und zeichnet es klein in das Thumb-Canvas.
|
|
* PDFs werden mit PDF.js gerendert.
|
|
*/
|
|
async function renderAllThumbs() {
|
|
const thumbs = document.querySelectorAll('.page-thumb');
|
|
for (const t of thumbs) {
|
|
const pageid = t.dataset.pageid;
|
|
const canvas = t.querySelector('.thumb-canvas');
|
|
if (!canvas || !pageid) continue;
|
|
try {
|
|
const r = await fetch(cfg.urls.page_image + '?pageid=' + pageid);
|
|
const ct = r.headers.get('Content-Type') || '';
|
|
const buf = await r.arrayBuffer();
|
|
if (ct.includes('pdf')) {
|
|
await renderThumbPdf(canvas, buf);
|
|
} else if (ct.includes('image')) {
|
|
await renderThumbImage(canvas, buf, ct);
|
|
}
|
|
} catch (e) { /* skip */ }
|
|
}
|
|
}
|
|
|
|
async function renderThumbImage(canvas, buf, mime) {
|
|
return new Promise(res => {
|
|
const blob = new Blob([buf], { type: mime });
|
|
const url = URL.createObjectURL(blob);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const maxSide = 200;
|
|
const ratio = Math.min(maxSide / img.width, maxSide / img.height);
|
|
canvas.width = Math.round(img.width * ratio);
|
|
canvas.height = Math.round(img.height * ratio);
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
URL.revokeObjectURL(url);
|
|
res();
|
|
};
|
|
img.onerror = () => { URL.revokeObjectURL(url); res(); };
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
async function renderThumbPdf(canvas, buf) {
|
|
if (!window.pdfjsLib) return;
|
|
try {
|
|
const doc = await pdfjsLib.getDocument({ data: buf.slice(0) }).promise;
|
|
const page = await doc.getPage(1);
|
|
const base = page.getViewport({ scale: 1 });
|
|
const scale = 200 / base.width;
|
|
const vp = page.getViewport({ scale: scale });
|
|
canvas.width = vp.width;
|
|
canvas.height = vp.height;
|
|
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
|
|
} catch (e) { /* skip */ }
|
|
}
|
|
|
|
function bindSortable() {
|
|
const list = document.getElementById('bericht-page-list');
|
|
if (!list || !window.Sortable) return;
|
|
Sortable.create(list, {
|
|
animation: 150,
|
|
onEnd: async () => {
|
|
const ids = Array.from(list.querySelectorAll('.page-thumb')).map(t => t.dataset.pageid);
|
|
const fd = new FormData();
|
|
fd.append('token', cfg.token);
|
|
fd.append('order', JSON.stringify(ids));
|
|
await fetch(cfg.urls.reorder_pages, { method: 'POST', body: fd });
|
|
}
|
|
});
|
|
}
|
|
|
|
function closePreviewModal() {
|
|
const m = document.getElementById('bericht-preview-modal');
|
|
if (m) m.style.display = 'none';
|
|
const ifr = document.getElementById('bericht-preview-iframe');
|
|
if (ifr) ifr.src = 'about:blank';
|
|
}
|
|
|
|
/* ---------- Helpers ---------- */
|
|
function toast(msg) {
|
|
const t = document.createElement('div');
|
|
t.className = 'bericht-toast';
|
|
t.textContent = msg;
|
|
document.body.appendChild(t);
|
|
setTimeout(() => t.remove(), 2000);
|
|
}
|
|
|
|
/* ---------- 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);
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
})();
|