All checks were successful
Deploy bericht / deploy (push) Has been skipped
This reverts commit 532d8e0c98.
438 lines
18 KiB
JavaScript
438 lines
18 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 fabricCanvas = null;
|
|
const pdfCanvas = document.getElementById('pdf-canvas');
|
|
let currentTool = 'select';
|
|
|
|
/* ---------- Init ---------- */
|
|
function init() {
|
|
// Fabric initialisieren (wird beim ersten Seitenrendern dimensioniert)
|
|
fabricCanvas = new fabric.Canvas('fabric-canvas', {
|
|
isDrawingMode: false,
|
|
selection: true,
|
|
});
|
|
fabricCanvas.freeDrawingBrush.color = document.getElementById('tool-color').value;
|
|
fabricCanvas.freeDrawingBrush.width = parseInt(document.getElementById('tool-stroke').value, 10);
|
|
|
|
// Erste Seite laden (wenn vorhanden)
|
|
const firstThumb = document.querySelector('#bericht-page-list .page-thumb');
|
|
if (firstThumb) loadPage(firstThumb);
|
|
|
|
bindThumbs();
|
|
bindToolbar();
|
|
bindAttachments();
|
|
bindExtraUpload();
|
|
bindActions();
|
|
bindSortable();
|
|
}
|
|
|
|
/* ---------- 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; // 4px Sicherheitsabstand
|
|
return Math.max(300, Math.min(1200, Math.floor(avail)));
|
|
}
|
|
|
|
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() {
|
|
// Die tatsächlich angezeigte Größe (nicht der Drawing-Buffer) bestimmt
|
|
// die Position des Fabric-Overlays. Wir setzen das Fabric-Canvas auf
|
|
// die Buffer-Größe (für scharfe Annotationen) und positionieren es
|
|
// exakt über dem sichtbaren PDF-Canvas.
|
|
fabricCanvas.setWidth(pdfCanvas.width);
|
|
fabricCanvas.setHeight(pdfCanvas.height);
|
|
|
|
// Warten bis der Browser die neue Größe applied hat
|
|
requestAnimationFrame(() => {
|
|
const rect = pdfCanvas.getBoundingClientRect();
|
|
const wrap = pdfCanvas.parentElement;
|
|
const wrapRect = wrap.getBoundingClientRect();
|
|
const fc = document.getElementById('fabric-canvas');
|
|
fc.style.position = 'absolute';
|
|
fc.style.left = (rect.left - wrapRect.left + wrap.scrollLeft) + 'px';
|
|
fc.style.top = (rect.top - wrapRect.top + wrap.scrollTop) + 'px';
|
|
fc.style.width = pdfCanvas.clientWidth + 'px';
|
|
fc.style.height = pdfCanvas.clientHeight + 'px';
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
} 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: rotate-left/right rotieren die SEITE (nicht das Fabric-Objekt)
|
|
document.getElementById('btn-rotate-left').addEventListener('click', () => rotatePage(-90));
|
|
document.getElementById('btn-rotate-right').addEventListener('click', () => rotatePage(90));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function applyTool() {
|
|
fabricCanvas.isDrawingMode = (currentTool === 'draw');
|
|
fabricCanvas.selection = (currentTool === 'select');
|
|
|
|
// Click-Handler für Shape-Tools
|
|
fabricCanvas.off('mouse:down', shapeDown);
|
|
if (['rect', 'circle', 'arrow', 'text'].includes(currentTool)) {
|
|
fabricCanvas.on('mouse:down', shapeDown);
|
|
}
|
|
}
|
|
|
|
function shapeDown(opt) {
|
|
const p = fabricCanvas.getPointer(opt.e);
|
|
const color = document.getElementById('tool-color').value;
|
|
const sw = parseInt(document.getElementById('tool-stroke').value, 10);
|
|
let shape = null;
|
|
if (currentTool === 'rect') {
|
|
shape = new fabric.Rect({ left: p.x, top: p.y, width: 100, height: 60, fill: 'transparent', stroke: color, strokeWidth: sw });
|
|
} else if (currentTool === 'circle') {
|
|
shape = new fabric.Circle({ left: p.x, top: p.y, radius: 40, fill: 'transparent', stroke: color, strokeWidth: sw });
|
|
} else if (currentTool === 'arrow') {
|
|
// Pfeil = Linie mit Pfeilspitze (vereinfacht)
|
|
shape = new fabric.Line([p.x, p.y, p.x + 100, p.y + 50], { stroke: color, strokeWidth: sw });
|
|
} else if (currentTool === 'text') {
|
|
shape = new fabric.IText('Text…', { left: p.x, top: p.y, fontSize: 24, fill: color });
|
|
}
|
|
if (shape) {
|
|
fabricCanvas.add(shape);
|
|
fabricCanvas.setActiveObject(shape);
|
|
// Nach dem Setzen wieder zurück auf Select, damit User direkt verschieben/skalieren kann
|
|
const sb = document.querySelector('.tool-btn[data-tool="select"]');
|
|
if (sb) sb.click();
|
|
}
|
|
}
|
|
|
|
/* ---------- 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');
|
|
}
|
|
|
|
function bindActions() {
|
|
document.getElementById('btn-save-draft').addEventListener('click', async () => {
|
|
await savePageAnnotations(true);
|
|
});
|
|
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) return;
|
|
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(); // einfacher als Thumbnail-Liste neu zu rendern
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|
|
}
|
|
|
|
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 });
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------- 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);
|
|
})();
|