From 195942a2f913afd9633c01c07ae596cf312030a4 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Thu, 9 Apr 2026 13:26:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=206=20=E2=80=94=20Client-WYSIWYG?= =?UTF-8?q?=20via=20Composite-PNG=20+=20Text-BG=20+=20Dark-Input-Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paradigmen-Wechsel: Editor rendert bei jedem Save sein Fabric-Canvas als PNG und lädt es hoch. PDF nutzt dieses PNG 1:1 statt die Shapes serverseitig nachzuzeichnen. Damit ist garantiert: was du im Editor siehst, ist EXAKT das was im PDF landet. Alle Pfeil/Text/Shape-Rendering-Bugs zwischen Fabric-JSON und PHP-Nachzeichnung sind Geschichte. Kernänderungen: 1. DB: Neue Spalte bericht_page.composite_path (Migration im init()) 2. ajax/save_annotations.php: nimmt multipart file 'composite' entgegen, speichert es unter bericht/work//composite_.png 3. lib/bericht.lib.php: bericht_render_page_to_pdf prüft composite_path zuerst — wenn vorhanden, wird eine Seite mit genau diesem PNG als volles Bild gerendert, fertig. Fallback auf alte Logik bei alten Berichten ohne Composite. 4. editor.js renderImage: Quellbild wird NICHT mehr auf pdfCanvas gezeichnet, sondern als fabric.Image ins Fabric-Canvas geladen — ZIEHBAR, SKALIERBAR, ROTIERBAR wie jedes andere Objekt. Mehrere Bilder auf einer Seite kein Problem mehr. 5. editor.js savePageAnnotations: nach Shape-State wird toDataURL mit multiplier:2 aufgerufen, PNG-Blob hochgeladen zusammen mit fabric_json (für spätere Edits) und note. 6. editor.js loadPage: wenn fabric_json existiert, wird dieses clientseitig wieder eingeladen (inkl. eingebettete Bilder) — das Quell-Bild wird nicht mehr neu aus der Quelle geholt. Bei leerer Seite läuft der alte Render-Flow. Phase 6 Bonus — Text mit Hintergrund: - Neuer color-picker 'BG:' in der Toolbar + 'Ø'-Button (kein BG) - Fabric IText bekommt textBackgroundColor + padding:6 - Bei selektiertem Text-Objekt wird BG live angewendet - Dataset-Flag 'active' toggelt zwischen ein/aus Dark-Input-Fix: - Textarea in .bericht-page-note nutzte --inputbackgroundcolor (existiert in awl-dark nicht → Fallback #fff = weiße Fläche mit schwarzer Schrift auf Dark-Theme) - Jetzt: --colorbackbody + --colortext + --colorboxbordertitle1 - Generischer Input-Style für alle Text-Eingaben in .bericht-editor Co-Authored-By: Claude Opus 4.6 (1M context) [deploy] --- ajax/save_annotations.php | 53 ++++++++-- bericht_card.php | 2 + class/bericht.class.php | 11 +- core/modules/modBericht.class.php | 5 + css/bericht.css | 38 ++++++- js/editor.js | 170 ++++++++++++++++++++++++------ lib/bericht.lib.php | 28 +++++ 7 files changed, 260 insertions(+), 47 deletions(-) diff --git a/ajax/save_annotations.php b/ajax/save_annotations.php index 8fa4653..115daab 100644 --- a/ajax/save_annotations.php +++ b/ajax/save_annotations.php @@ -1,8 +1,16 @@ hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403); @@ -15,15 +23,38 @@ $note = (string) ($_POST['note'] ?? ''); $rotation = (int) ($_POST['rotation'] ?? 0); $rotation = (($rotation % 360) + 360) % 360; -// Page laden -$res = $db->query("SELECT rowid FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid)); -if (!$res || !$db->fetch_object($res)) bericht_ajax_fail('Page nicht gefunden', 404); +// Page laden und fk_bericht ermitteln +$res = $db->query("SELECT rowid, fk_bericht FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid)); +if (!$res || !($row = $db->fetch_object($res))) bericht_ajax_fail('Page nicht gefunden', 404); +$fk_bericht = (int) $row->fk_bericht; -$sql = "UPDATE ".$db->prefix()."bericht_page SET " - ."fabric_json = ".($fabric !== '' ? "'".$db->escape($fabric)."'" : "NULL")."," - ."note = ".($note !== '' ? "'".$db->escape($note)."'" : "NULL")."," - ."rotation = ".((int) $rotation) - ." WHERE rowid = ".((int) $pageid); +// Composite-PNG speichern wenn hochgeladen +$composite_relpath = null; +if (!empty($_FILES['composite']['tmp_name']) && is_uploaded_file($_FILES['composite']['tmp_name'])) { + $workdir = DOL_DATA_ROOT.'/bericht/work/'.$fk_bericht; + if (!is_dir($workdir)) dol_mkdir($workdir); + $filename = 'composite_'.$pageid.'.png'; + $target = $workdir.'/'.$filename; + if (move_uploaded_file($_FILES['composite']['tmp_name'], $target)) { + $composite_relpath = str_replace(DOL_DATA_ROOT.'/', '', $target); + } +} + +// Prüfen ob composite_path-Spalte existiert +$has_composite = false; +$ccheck = $db->query("SHOW COLUMNS FROM ".$db->prefix()."bericht_page LIKE 'composite_path'"); +if ($ccheck && $db->num_rows($ccheck) > 0) $has_composite = true; + +$sets = array( + "fabric_json = ".($fabric !== '' ? "'".$db->escape($fabric)."'" : "NULL"), + "note = ".($note !== '' ? "'".$db->escape($note)."'" : "NULL"), + "rotation = ".((int) $rotation), +); +if ($composite_relpath !== null && $has_composite) { + $sets[] = "composite_path = '".$db->escape($composite_relpath)."'"; +} + +$sql = "UPDATE ".$db->prefix()."bericht_page SET ".implode(', ', $sets)." WHERE rowid = ".((int) $pageid); if (!$db->query($sql)) bericht_ajax_fail('DB-Fehler: '.$db->lasterror()); -bericht_ajax_ok(); +bericht_ajax_ok(array('composite_saved' => $composite_relpath !== null)); diff --git a/bericht_card.php b/bericht_card.php index 33fb827..d0c1cfb 100644 --- a/bericht_card.php +++ b/bericht_card.php @@ -452,6 +452,8 @@ if (!$bericht) { print ''; print ''; print ''; + print ''; + print ''; print ''; // Zoom print ''; diff --git a/class/bericht.class.php b/class/bericht.class.php index da37c99..44da18f 100644 --- a/class/bericht.class.php +++ b/class/bericht.class.php @@ -410,6 +410,7 @@ class BerichtPage public $fabric_json; public $note; public $title; // Optional Titel/Zwischentitel der Seite + public $composite_path; // Phase 6: vom Editor generiertes PNG der gesamten Seite public $layout = 'single'; public $image_scale = 1.0; public $image_align = 'fit'; @@ -572,10 +573,17 @@ class BerichtPage public static function fetchAllForBericht(DoliDB $db, $fk_bericht) { $list = array(); + // composite_path kann noch fehlen falls Migration nicht durchgelaufen — defensiv + $has_composite = false; + $ccheck = $db->query("SHOW COLUMNS FROM ".$db->prefix()."bericht_page LIKE 'composite_path'"); + if ($ccheck && $db->num_rows($ccheck) > 0) $has_composite = true; + + $composite_col = $has_composite ? 'composite_path' : "NULL AS composite_path"; $sql = "SELECT rowid, fk_bericht, page_order, source_type, source_path, source_page, rotation, fabric_json, note, title," ." COALESCE(layout, 'single') AS layout," ." COALESCE(image_scale, 1.0) AS image_scale," - ." COALESCE(image_align, 'fit') AS image_align" + ." COALESCE(image_align, 'fit') AS image_align," + ." ".$composite_col ." FROM ".$db->prefix()."bericht_page" ." WHERE fk_bericht = ".((int) $fk_bericht) ." ORDER BY page_order ASC, rowid ASC"; @@ -593,6 +601,7 @@ class BerichtPage $p->fabric_json = $obj->fabric_json; $p->note = $obj->note; $p->title = $obj->title; + $p->composite_path = $obj->composite_path ?? null; $p->layout = $obj->layout; $p->image_scale = (float) $obj->image_scale; $p->image_align = $obj->image_align; diff --git a/core/modules/modBericht.class.php b/core/modules/modBericht.class.php index 5390f6b..85f170d 100644 --- a/core/modules/modBericht.class.php +++ b/core/modules/modBericht.class.php @@ -160,6 +160,11 @@ class modBericht extends DolibarrModules // Phase 5.3: Versionierung "ALTER TABLE ".$this->db->prefix()."bericht ADD COLUMN version INT DEFAULT 1", "ALTER TABLE ".$this->db->prefix()."bericht ADD COLUMN fk_bericht_parent INT DEFAULT NULL", + // Phase 6: Composite-PNG (Client-WYSIWYG). Wenn gesetzt, wird die Seite + // komplett aus diesem einen PNG gerendert statt aus source_path + fabric_json. + // Der Editor rendert sein Fabric-Canvas bei jedem Save zu einem PNG und + // lädt es hoch — damit ist PDF-Output identisch mit Editor-Anzeige. + "ALTER TABLE ".$this->db->prefix()."bericht_page ADD COLUMN composite_path VARCHAR(512) DEFAULT NULL", // Phase 5.9: Materialliste pro Auftrag "CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_material (" ."rowid INT AUTO_INCREMENT PRIMARY KEY," diff --git a/css/bericht.css b/css/bericht.css index f0662eb..d45763d 100644 --- a/css/bericht.css +++ b/css/bericht.css @@ -213,16 +213,46 @@ .bericht-page-note { margin-top: 8px; } .bericht-page-note label { display: block; - color: var(--colortextbackhmenu, inherit); + color: var(--colortext, #e0e0e0); font-size: 12px; margin-bottom: 4px; } .bericht-page-note textarea { width: 100%; box-sizing: border-box; - background: var(--inputbackgroundcolor, #fff); - color: var(--inputtextcolor, #000); - border: 1px solid var(--colorboxbordertitle1, #ccc); + background: var(--colorbackbody, #2a2a30); + color: var(--colortext, #e0e0e0); + border: 1px solid var(--colorboxbordertitle1, #555); + border-radius: 4px; + padding: 10px 12px; + font-size: 14px; + font-family: inherit; + min-height: 60px; + resize: vertical; +} +.bericht-page-note textarea:focus { + outline: 2px solid var(--colortextlink, #7aa2f7); + outline-offset: -1px; +} + +/* Generische Input-Styles im Editor — Fallback damit alles zum Dark-Theme passt */ +.bericht-editor textarea, +.bericht-editor input[type="text"], +.bericht-editor input[type="number"], +.bericht-editor input[type="email"], +.bericht-editor input[type="search"] { + background: var(--colorbackbody, #2a2a30); + color: var(--colortext, #e0e0e0); + border: 1px solid var(--colorboxbordertitle1, #555); + border-radius: 4px; + padding: 8px 10px; + font-size: 14px; + font-family: inherit; +} +.bericht-editor textarea:focus, +.bericht-editor input:focus { + outline: 2px solid var(--colortextlink, #7aa2f7); + outline-offset: -1px; } .bericht-pages-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } diff --git a/js/editor.js b/js/editor.js index 3c44e25..81a3350 100644 --- a/js/editor.js +++ b/js/editor.js @@ -158,8 +158,33 @@ fabricCanvas.clear(); document.getElementById('page-note').value = ''; - await loadPageMeta(); // setzt currentPageRotation falls vorhanden - await rerenderCurrent(); // rendert mit Rotation + await loadPageMeta(); // setzt currentPageRotation + ggf. loadedFabricJson + + if (loadedFabricJson) { + // Existierender State wird geladen — enthält bereits Bild, Shapes, Text etc. + try { + const parsed = (typeof loadedFabricJson === 'string') ? JSON.parse(loadedFabricJson) : loadedFabricJson; + // Canvas-Dimensionen aus dem geladenen JSON übernehmen und skalieren + 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 ctx = pdfCanvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, pdfCanvas.width, pdfCanvas.height); + resizeFabricToCanvas(); + await new Promise(res => { + fabricCanvas.loadFromJSON(parsed, () => { fabricCanvas.renderAll(); res(); }); + }); + } catch (e) { + console.warn('Fabric-JSON restore fehlgeschlagen, fallback auf Neu-Render:', e); + await rerenderCurrent(); + } + } else { + // Erster Besuch der Seite: Quellbild frisch als Fabric-Image laden + await rerenderCurrent(); + } } /** @@ -208,36 +233,60 @@ } 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); - 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(); + return new Promise((res) => { + fabric.Image.fromURL(url, (fabricImg) => { + // Wenn schon ein Bild-Objekt im Canvas ist (nach re-render), + // altes entfernen bevor neues rein kommt + const existing = fabricCanvas.getObjects().find(o => o.bgImage === true); + if (existing) fabricCanvas.remove(existing); + // Markiere dieses Objekt als "Hintergrundbild" (bleibt aber editierbar) + fabricImg.bgImage = true; + + // Auf Canvas-Größe skalieren (contain) + 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, + }); + // Ziehbares Hintergrundbild ganz nach hinten + fabricCanvas.add(fabricImg); + fabricImg.sendToBack(); + fabricCanvas.requestRenderAll(); URL.revokeObjectURL(url); res(); - }; - img.src = url; + }, { crossOrigin: 'anonymous' }); }); - resizeFabricToCanvas(); } function resizeFabricToCanvas() { @@ -267,14 +316,14 @@ }); } + 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) { - fabricCanvas.loadFromJSON(data.fabric_json, () => fabricCanvas.renderAll()); - } + if (data.fabric_json) loadedFabricJson = data.fabric_json; if (data.note) document.getElementById('page-note').value = data.note; if (typeof data.rotation !== 'undefined' && data.rotation !== null) { currentPageRotation = parseInt(data.rotation, 10) || 0; @@ -289,7 +338,6 @@ 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'; }); @@ -392,6 +440,31 @@ 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); @@ -479,12 +552,17 @@ 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 + fill: color, + textBackgroundColor: bgColor || '', + padding: bgColor ? 6 : 0, }); fabricCanvas.add(t); fabricCanvas.setActiveObject(t); @@ -612,9 +690,39 @@ const fd = new FormData(); fd.append('token', cfg.token); fd.append('pageid', currentPageId); - fd.append('fabric_json', JSON.stringify(fabricCanvas.toJSON())); + // Fabric-JSON MIT Hintergrundbild (bgImage-Objekte werden serialisiert) + fd.append('fabric_json', JSON.stringify(fabricCanvas.toJSON(['bgImage']))); fd.append('note', document.getElementById('page-note').value || ''); fd.append('rotation', currentPageRotation); + + // Phase 6: Composite-PNG der gesamten Seite generieren und mitschicken + 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'); diff --git a/lib/bericht.lib.php b/lib/bericht.lib.php index 0a8e276..0b2c0af 100644 --- a/lib/bericht.lib.php +++ b/lib/bericht.lib.php @@ -307,6 +307,34 @@ function bericht_render_cover_for_preview($template_path, $bericht, $parent, $te */ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded) { + // Phase 6: Wenn der Editor ein Composite-PNG hochgeladen hat, nutzen wir das + // direkt — ein Bild über die ganze Seite. Keine Server-Shape-Logik nötig. + if (!empty($page->composite_path)) { + $full = bericht_resolve_data_path($page->composite_path); + if ($full && file_exists($full)) { + $pdf->AddPage($ori, $fmt); + $pageW = $pdf->getPageWidth(); + $pageH = $pdf->getPageHeight(); + $margin = 10; + $maxW = $pageW - 2 * $margin; + $maxH = $pageH - 2 * $margin - (empty($page->note) ? 0 : 10); + list($iw, $ih) = @getimagesize($full); + if ($iw && $ih) { + $ratio = min($maxW / $iw, $maxH / $ih); + $w = $iw * $ratio; $h = $ih * $ratio; + $x = ($pageW - $w) / 2; + $y = $margin; + $pdf->Image($full, $x, $y, $w, $h); + } + if (!empty($page->note)) { + $pdf->SetY(-20); + $pdf->SetFont('helvetica', 'I', 9); + $pdf->MultiCell(0, 5, $page->note, 0, 'L'); + } + return; + } + } + $layout = $page->layout ?: 'single'; // Spezial-Fall: reine Titel-Seite (kein Bild, nur großer Titel zentriert)