bericht/js/editor.js
Eduard Wisch 40fb738ccf
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
feat: Seitenrotation (Hoch/Quer) per Toolbar-Buttons
Die ⟲/⟳-Buttons rotieren jetzt die SEITE statt Fabric-Objekte:
- Image: ctx.rotate beim drawImage, Buffer-Größe getauscht
- PDF: pdfjsLib viewport mit rotation-Param
- Rotation in llx_bericht_page.rotation persistiert
- Beim Seitenwechsel wird die gespeicherte Rotation aus page_meta geladen
- Annotationen werden bei Rotation gelöscht (rotieren VOR dem Annotieren)

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

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);
})();