feat: Phase 6 — Client-WYSIWYG via Composite-PNG + Text-BG + Dark-Input-Fix
All checks were successful
Deploy bericht / deploy (push) Successful in 2s
All checks were successful
Deploy bericht / deploy (push) Successful in 2s
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/<fkb>/composite_<pid>.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) <noreply@anthropic.com> [deploy]
This commit is contained in:
parent
4dad496788
commit
195942a2f9
7 changed files with 260 additions and 47 deletions
|
|
@ -1,8 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
/* Speichert Fabric.js-JSON für eine Seite + ggf. Notiz.
|
/* Speichert Fabric.js-JSON + Notiz + optional Composite-PNG für eine Seite.
|
||||||
* POST: pageid, fabric_json, note, token
|
*
|
||||||
|
* POST:
|
||||||
|
* pageid — Seiten-ID
|
||||||
|
* fabric_json — Fabric-Serialisierung (für späteren Edit)
|
||||||
|
* note — Notiz für den PDF-Footer
|
||||||
|
* rotation — Rotation 0/90/180/270
|
||||||
|
* composite — (optional) multipart/form-data file: PNG der kompletten Seite
|
||||||
|
* token
|
||||||
*/
|
*/
|
||||||
require_once __DIR__.'/_inc.php';
|
require_once __DIR__.'/_inc.php';
|
||||||
|
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
|
||||||
|
|
||||||
global $db, $user;
|
global $db, $user;
|
||||||
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
|
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
|
||||||
|
|
@ -15,15 +23,38 @@ $note = (string) ($_POST['note'] ?? '');
|
||||||
$rotation = (int) ($_POST['rotation'] ?? 0);
|
$rotation = (int) ($_POST['rotation'] ?? 0);
|
||||||
$rotation = (($rotation % 360) + 360) % 360;
|
$rotation = (($rotation % 360) + 360) % 360;
|
||||||
|
|
||||||
// Page laden
|
// Page laden und fk_bericht ermitteln
|
||||||
$res = $db->query("SELECT rowid FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
|
$res = $db->query("SELECT rowid, fk_bericht FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
|
||||||
if (!$res || !$db->fetch_object($res)) bericht_ajax_fail('Page nicht gefunden', 404);
|
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 "
|
// Composite-PNG speichern wenn hochgeladen
|
||||||
."fabric_json = ".($fabric !== '' ? "'".$db->escape($fabric)."'" : "NULL").","
|
$composite_relpath = null;
|
||||||
."note = ".($note !== '' ? "'".$db->escape($note)."'" : "NULL").","
|
if (!empty($_FILES['composite']['tmp_name']) && is_uploaded_file($_FILES['composite']['tmp_name'])) {
|
||||||
."rotation = ".((int) $rotation)
|
$workdir = DOL_DATA_ROOT.'/bericht/work/'.$fk_bericht;
|
||||||
." WHERE rowid = ".((int) $pageid);
|
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());
|
if (!$db->query($sql)) bericht_ajax_fail('DB-Fehler: '.$db->lasterror());
|
||||||
|
|
||||||
bericht_ajax_ok();
|
bericht_ajax_ok(array('composite_saved' => $composite_relpath !== null));
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,8 @@ if (!$bericht) {
|
||||||
print '<label class="text-tool-option" title="Schriftgröße in Pixel">Größe: <input type="number" id="tool-fontsize" min="8" max="120" value="24" style="width:60px" title="Schriftgröße"></label>';
|
print '<label class="text-tool-option" title="Schriftgröße in Pixel">Größe: <input type="number" id="tool-fontsize" min="8" max="120" value="24" style="width:60px" title="Schriftgröße"></label>';
|
||||||
print '<label class="text-tool-option" title="Fett"><input type="checkbox" id="tool-bold" title="Fett"> <b>B</b></label>';
|
print '<label class="text-tool-option" title="Fett"><input type="checkbox" id="tool-bold" title="Fett"> <b>B</b></label>';
|
||||||
print '<label class="text-tool-option" title="Kursiv"><input type="checkbox" id="tool-italic" title="Kursiv"> <i>I</i></label>';
|
print '<label class="text-tool-option" title="Kursiv"><input type="checkbox" id="tool-italic" title="Kursiv"> <i>I</i></label>';
|
||||||
|
print '<label class="text-tool-option" title="Text-Hintergrundfarbe">BG: <input type="color" id="tool-bgcolor" value="#ffffff"></label>';
|
||||||
|
print '<button type="button" id="tool-bg-off" class="text-tool-option" title="Kein Hintergrund" style="padding:4px 8px;">Ø</button>';
|
||||||
print '<span class="sep"></span>';
|
print '<span class="sep"></span>';
|
||||||
// Zoom
|
// Zoom
|
||||||
print '<button type="button" id="btn-zoom-out" title="Verkleinern (Zoom -)">🔍−</button>';
|
print '<button type="button" id="btn-zoom-out" title="Verkleinern (Zoom -)">🔍−</button>';
|
||||||
|
|
|
||||||
|
|
@ -410,6 +410,7 @@ class BerichtPage
|
||||||
public $fabric_json;
|
public $fabric_json;
|
||||||
public $note;
|
public $note;
|
||||||
public $title; // Optional Titel/Zwischentitel der Seite
|
public $title; // Optional Titel/Zwischentitel der Seite
|
||||||
|
public $composite_path; // Phase 6: vom Editor generiertes PNG der gesamten Seite
|
||||||
public $layout = 'single';
|
public $layout = 'single';
|
||||||
public $image_scale = 1.0;
|
public $image_scale = 1.0;
|
||||||
public $image_align = 'fit';
|
public $image_align = 'fit';
|
||||||
|
|
@ -572,10 +573,17 @@ class BerichtPage
|
||||||
public static function fetchAllForBericht(DoliDB $db, $fk_bericht)
|
public static function fetchAllForBericht(DoliDB $db, $fk_bericht)
|
||||||
{
|
{
|
||||||
$list = array();
|
$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,"
|
$sql = "SELECT rowid, fk_bericht, page_order, source_type, source_path, source_page, rotation, fabric_json, note, title,"
|
||||||
." COALESCE(layout, 'single') AS layout,"
|
." COALESCE(layout, 'single') AS layout,"
|
||||||
." COALESCE(image_scale, 1.0) AS image_scale,"
|
." 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"
|
." FROM ".$db->prefix()."bericht_page"
|
||||||
." WHERE fk_bericht = ".((int) $fk_bericht)
|
." WHERE fk_bericht = ".((int) $fk_bericht)
|
||||||
." ORDER BY page_order ASC, rowid ASC";
|
." ORDER BY page_order ASC, rowid ASC";
|
||||||
|
|
@ -593,6 +601,7 @@ class BerichtPage
|
||||||
$p->fabric_json = $obj->fabric_json;
|
$p->fabric_json = $obj->fabric_json;
|
||||||
$p->note = $obj->note;
|
$p->note = $obj->note;
|
||||||
$p->title = $obj->title;
|
$p->title = $obj->title;
|
||||||
|
$p->composite_path = $obj->composite_path ?? null;
|
||||||
$p->layout = $obj->layout;
|
$p->layout = $obj->layout;
|
||||||
$p->image_scale = (float) $obj->image_scale;
|
$p->image_scale = (float) $obj->image_scale;
|
||||||
$p->image_align = $obj->image_align;
|
$p->image_align = $obj->image_align;
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,11 @@ class modBericht extends DolibarrModules
|
||||||
// Phase 5.3: Versionierung
|
// Phase 5.3: Versionierung
|
||||||
"ALTER TABLE ".$this->db->prefix()."bericht ADD COLUMN version INT DEFAULT 1",
|
"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",
|
"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
|
// Phase 5.9: Materialliste pro Auftrag
|
||||||
"CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_material ("
|
"CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_material ("
|
||||||
."rowid INT AUTO_INCREMENT PRIMARY KEY,"
|
."rowid INT AUTO_INCREMENT PRIMARY KEY,"
|
||||||
|
|
|
||||||
|
|
@ -213,16 +213,46 @@
|
||||||
.bericht-page-note { margin-top: 8px; }
|
.bericht-page-note { margin-top: 8px; }
|
||||||
.bericht-page-note label {
|
.bericht-page-note label {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--colortextbackhmenu, inherit);
|
color: var(--colortext, #e0e0e0);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.bericht-page-note textarea {
|
.bericht-page-note textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--inputbackgroundcolor, #fff);
|
background: var(--colorbackbody, #2a2a30);
|
||||||
color: var(--inputtextcolor, #000);
|
color: var(--colortext, #e0e0e0);
|
||||||
border: 1px solid var(--colorboxbordertitle1, #ccc);
|
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; }
|
.bericht-pages-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||||
|
|
|
||||||
170
js/editor.js
170
js/editor.js
|
|
@ -158,8 +158,33 @@
|
||||||
fabricCanvas.clear();
|
fabricCanvas.clear();
|
||||||
document.getElementById('page-note').value = '';
|
document.getElementById('page-note').value = '';
|
||||||
|
|
||||||
await loadPageMeta(); // setzt currentPageRotation falls vorhanden
|
await loadPageMeta(); // setzt currentPageRotation + ggf. loadedFabricJson
|
||||||
await rerenderCurrent(); // rendert mit Rotation
|
|
||||||
|
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) {
|
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 blob = new Blob([arrayBuffer], { type: mime });
|
||||||
const url = URL.createObjectURL(blob);
|
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');
|
return new Promise((res) => {
|
||||||
ctx.clearRect(0, 0, pdfCanvas.width, pdfCanvas.height);
|
fabric.Image.fromURL(url, (fabricImg) => {
|
||||||
ctx.save();
|
// Wenn schon ein Bild-Objekt im Canvas ist (nach re-render),
|
||||||
ctx.translate(pdfCanvas.width / 2, pdfCanvas.height / 2);
|
// altes entfernen bevor neues rein kommt
|
||||||
ctx.rotate(currentPageRotation * Math.PI / 180);
|
const existing = fabricCanvas.getObjects().find(o => o.bgImage === true);
|
||||||
const dw = img.width * ratio;
|
if (existing) fabricCanvas.remove(existing);
|
||||||
const dh = img.height * ratio;
|
|
||||||
ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh);
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
|
// 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);
|
URL.revokeObjectURL(url);
|
||||||
res();
|
res();
|
||||||
};
|
}, { crossOrigin: 'anonymous' });
|
||||||
img.src = url;
|
|
||||||
});
|
});
|
||||||
resizeFabricToCanvas();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizeFabricToCanvas() {
|
function resizeFabricToCanvas() {
|
||||||
|
|
@ -267,14 +316,14 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let loadedFabricJson = null;
|
||||||
async function loadPageMeta() {
|
async function loadPageMeta() {
|
||||||
|
loadedFabricJson = null;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(cfg.urls.save_annotations.replace('save_annotations', 'page_meta') + '?pageid=' + currentPageId);
|
const r = await fetch(cfg.urls.save_annotations.replace('save_annotations', 'page_meta') + '?pageid=' + currentPageId);
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (data.fabric_json) {
|
if (data.fabric_json) loadedFabricJson = data.fabric_json;
|
||||||
fabricCanvas.loadFromJSON(data.fabric_json, () => fabricCanvas.renderAll());
|
|
||||||
}
|
|
||||||
if (data.note) document.getElementById('page-note').value = data.note;
|
if (data.note) document.getElementById('page-note').value = data.note;
|
||||||
if (typeof data.rotation !== 'undefined' && data.rotation !== null) {
|
if (typeof data.rotation !== 'undefined' && data.rotation !== null) {
|
||||||
currentPageRotation = parseInt(data.rotation, 10) || 0;
|
currentPageRotation = parseInt(data.rotation, 10) || 0;
|
||||||
|
|
@ -289,7 +338,6 @@
|
||||||
if (lEl) lEl.value = currentPageLayout;
|
if (lEl) lEl.value = currentPageLayout;
|
||||||
if (sEl) sEl.value = currentPageScale.toString();
|
if (sEl) sEl.value = currentPageScale.toString();
|
||||||
if (aEl) aEl.value = currentPageAlign;
|
if (aEl) aEl.value = currentPageAlign;
|
||||||
// Single-only Felder ein/ausblenden
|
|
||||||
document.querySelectorAll('.single-only').forEach(el => {
|
document.querySelectorAll('.single-only').forEach(el => {
|
||||||
el.style.display = (currentPageLayout === 'single') ? '' : 'none';
|
el.style.display = (currentPageLayout === 'single') ? '' : 'none';
|
||||||
});
|
});
|
||||||
|
|
@ -392,6 +440,31 @@
|
||||||
boldChk.addEventListener('change', applyTextProps);
|
boldChk.addEventListener('change', applyTextProps);
|
||||||
italicChk.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
|
// Bei Selektion eines Text-Objekts die Toolbar-Werte synchronisieren
|
||||||
fabricCanvas.on('selection:created', syncTextToolbar);
|
fabricCanvas.on('selection:created', syncTextToolbar);
|
||||||
fabricCanvas.on('selection:updated', syncTextToolbar);
|
fabricCanvas.on('selection:updated', syncTextToolbar);
|
||||||
|
|
@ -479,12 +552,17 @@
|
||||||
const fs = parseInt(document.getElementById('tool-fontsize').value, 10) || 24;
|
const fs = parseInt(document.getElementById('tool-fontsize').value, 10) || 24;
|
||||||
const bold = document.getElementById('tool-bold').checked;
|
const bold = document.getElementById('tool-bold').checked;
|
||||||
const ital = document.getElementById('tool-italic').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…', {
|
const t = new fabric.IText('Text…', {
|
||||||
left: p.x, top: p.y,
|
left: p.x, top: p.y,
|
||||||
fontFamily: ff, fontSize: fs,
|
fontFamily: ff, fontSize: fs,
|
||||||
fontWeight: bold ? 'bold' : 'normal',
|
fontWeight: bold ? 'bold' : 'normal',
|
||||||
fontStyle: ital ? 'italic' : 'normal',
|
fontStyle: ital ? 'italic' : 'normal',
|
||||||
fill: color
|
fill: color,
|
||||||
|
textBackgroundColor: bgColor || '',
|
||||||
|
padding: bgColor ? 6 : 0,
|
||||||
});
|
});
|
||||||
fabricCanvas.add(t);
|
fabricCanvas.add(t);
|
||||||
fabricCanvas.setActiveObject(t);
|
fabricCanvas.setActiveObject(t);
|
||||||
|
|
@ -612,9 +690,39 @@
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('token', cfg.token);
|
fd.append('token', cfg.token);
|
||||||
fd.append('pageid', currentPageId);
|
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('note', document.getElementById('page-note').value || '');
|
||||||
fd.append('rotation', currentPageRotation);
|
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 r = await fetch(cfg.urls.save_annotations, { method: 'POST', body: fd });
|
||||||
const data = await r.json().catch(() => ({}));
|
const data = await r.json().catch(() => ({}));
|
||||||
if (showMessage && data.success) toast('Seite gespeichert');
|
if (showMessage && data.success) toast('Seite gespeichert');
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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';
|
$layout = $page->layout ?: 'single';
|
||||||
|
|
||||||
// Spezial-Fall: reine Titel-Seite (kein Bild, nur großer Titel zentriert)
|
// Spezial-Fall: reine Titel-Seite (kein Bild, nur großer Titel zentriert)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue