Feature: Manueller Belegzuschnitt per Canvas-UI
This commit is contained in:
parent
517593ab03
commit
aa36a16c5a
5 changed files with 277 additions and 0 deletions
|
|
@ -51,6 +51,7 @@ $router->get('/belege/{id}', fn($p) => (new BelegController())->show($p));
|
||||||
$router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p));
|
$router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p));
|
||||||
$router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p));
|
$router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p));
|
||||||
$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p));
|
$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p));
|
||||||
|
$router->post('/belege/{id}/crop', fn($p) => (new BelegController())->cropImage($p));
|
||||||
$router->post('/belege/{id}/vorschlag/generate', fn($p) => (new BelegController())->generateVorschlag($p));
|
$router->post('/belege/{id}/vorschlag/generate', fn($p) => (new BelegController())->generateVorschlag($p));
|
||||||
$router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController())->resetVorschlag($p));
|
$router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController())->resetVorschlag($p));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,58 @@ class BelegController
|
||||||
View::redirect('/belege', 'Beleg gelöscht.');
|
View::redirect('/belege', 'Beleg gelöscht.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function cropImage(array $params): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$beleg = Beleg::find($id);
|
||||||
|
|
||||||
|
if (!$beleg) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Beleg nicht gefunden.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($beleg['locked']) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'GoBD: Gesperrter Beleg kann nicht verändert werden.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!in_array($beleg['mime_type'], ['image/jpeg', 'image/png'], true)) {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode(['error' => 'Zuschneiden nur für JPEG und PNG möglich.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$x = (int)($body['x'] ?? 0);
|
||||||
|
$y = (int)($body['y'] ?? 0);
|
||||||
|
$w = (int)($body['w'] ?? 0);
|
||||||
|
$h = (int)($body['h'] ?? 0);
|
||||||
|
|
||||||
|
if ($w < 10 || $h < 10) {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode(['error' => 'Auswahlbereich zu klein (mindestens 10×10 Pixel).']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||||
|
if (!file_exists($absPath)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Datei nicht gefunden.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$svc = new ImageCropService();
|
||||||
|
if (!$svc->cropToRect($absPath, $x, $y, $w, $h)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Zuschnitt fehlgeschlagen.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Beleg::updateHash($id, hash_file('sha256', $absPath));
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
private function renderView(string $template, array $data): string
|
private function renderView(string $template, array $data): string
|
||||||
{
|
{
|
||||||
extract($data, EXTR_SKIP);
|
extract($data, EXTR_SKIP);
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,14 @@ class Beleg
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function updateHash(int $id, string $hash): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET file_hash = ?, updated_at = NOW() WHERE id = ?',
|
||||||
|
[$hash, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function saveBelegText(int $id, string $text): void
|
public static function saveBelegText(int $id, string $text): void
|
||||||
{
|
{
|
||||||
Database::execute(
|
Database::execute(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,50 @@ class ImageCropService
|
||||||
private const PADDING = 25; // Pixel-Abstand, der nach dem Zuschnitt ringsum erhalten bleibt
|
private const PADDING = 25; // Pixel-Abstand, der nach dem Zuschnitt ringsum erhalten bleibt
|
||||||
private const MIN_CROP_GAIN = 0.03; // Mindestens 3 % Einsparung, sonst kein Zuschnitt
|
private const MIN_CROP_GAIN = 0.03; // Mindestens 3 % Einsparung, sonst kein Zuschnitt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schneidet ein Bild auf den angegebenen Bereich zu (manueller Zuschnitt).
|
||||||
|
* Koordinaten und Maße beziehen sich auf das Originalbild in Pixeln.
|
||||||
|
* Gibt true zurück, wenn das Bild gespeichert wurde.
|
||||||
|
*/
|
||||||
|
public function cropToRect(string $path, int $x, int $y, int $w, int $h): bool
|
||||||
|
{
|
||||||
|
$mime = mime_content_type($path);
|
||||||
|
|
||||||
|
$img = match ($mime) {
|
||||||
|
'image/jpeg' => imagecreatefromjpeg($path),
|
||||||
|
'image/png' => imagecreatefrompng($path),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!$img instanceof \GdImage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$imgW = imagesx($img);
|
||||||
|
$imgH = imagesy($img);
|
||||||
|
|
||||||
|
// Koordinaten auf Bildgrenzen klemmen
|
||||||
|
$x = max(0, min($x, $imgW - 1));
|
||||||
|
$y = max(0, min($y, $imgH - 1));
|
||||||
|
$w = max(1, min($w, $imgW - $x));
|
||||||
|
$h = max(1, min($h, $imgH - $y));
|
||||||
|
|
||||||
|
$cropped = imagecrop($img, ['x' => $x, 'y' => $y, 'width' => $w, 'height' => $h]);
|
||||||
|
imagedestroy($img);
|
||||||
|
|
||||||
|
if (!$cropped instanceof \GdImage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$saved = match ($mime) {
|
||||||
|
'image/jpeg' => imagejpeg($cropped, $path, 92),
|
||||||
|
'image/png' => imagepng($cropped, $path),
|
||||||
|
};
|
||||||
|
|
||||||
|
imagedestroy($cropped);
|
||||||
|
return $saved;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schneidet weißen/hellen Rand eines JPEG- oder PNG-Bilds automatisch weg.
|
* Schneidet weißen/hellen Rand eines JPEG- oder PNG-Bilds automatisch weg.
|
||||||
* Gibt true zurück, wenn das Bild tatsächlich verändert wurde.
|
* Gibt true zurück, wenn das Bild tatsächlich verändert wurde.
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,9 @@ $suppliers = $suppliers ?? [];
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-left:auto;display:flex;gap:.5rem">
|
<div style="margin-left:auto;display:flex;gap:.5rem">
|
||||||
<a href="/belege/<?= $h($beleg['id']) ?>/file" target="_blank" class="btn btn-secondary btn-sm">Datei öffnen</a>
|
<a href="/belege/<?= $h($beleg['id']) ?>/file" target="_blank" class="btn btn-secondary btn-sm">Datei öffnen</a>
|
||||||
|
<?php if (!$beleg['locked'] && in_array($beleg['mime_type'], ['image/jpeg', 'image/png'])): ?>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="openCropModal()">Zuschneiden</button>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if (!$beleg['locked']): ?>
|
<?php if (!$beleg['locked']): ?>
|
||||||
<button type="button" class="btn btn-secondary btn-sm"
|
<button type="button" class="btn btn-secondary btn-sm"
|
||||||
onclick="document.getElementById('beleg-meta-form').style.display = document.getElementById('beleg-meta-form').style.display === 'none' ? 'block' : 'none'">
|
onclick="document.getElementById('beleg-meta-form').style.display = document.getElementById('beleg-meta-form').style.display === 'none' ? 'block' : 'none'">
|
||||||
|
|
@ -739,6 +742,175 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
</script>
|
</script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!$beleg['locked'] && in_array($beleg['mime_type'], ['image/jpeg', 'image/png'])): ?>
|
||||||
|
<!-- Crop-Modal -->
|
||||||
|
<div class="modal-overlay" id="crop-modal-overlay" style="display:none;align-items:center;justify-content:center">
|
||||||
|
<div class="modal" style="max-width:95vw;width:auto;padding:1.25rem">
|
||||||
|
<div class="modal-title" style="margin-bottom:.75rem">Beleg zuschneiden</div>
|
||||||
|
<p class="text-sm text-muted" style="margin-bottom:.75rem">
|
||||||
|
Ziehe einen Rahmen über den gewünschten Ausschnitt. Das Original wird unwiderruflich ersetzt.
|
||||||
|
</p>
|
||||||
|
<div id="crop-canvas-wrap" style="position:relative;display:inline-block;cursor:crosshair;line-height:0;border:1px solid var(--color-border);border-radius:var(--radius)">
|
||||||
|
<canvas id="crop-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:.75rem;display:flex;gap:.5rem;align-items:center">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="crop-confirm-btn" disabled onclick="confirmCrop()">Zuschnitt übernehmen</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="closeCropModal()">Abbrechen</button>
|
||||||
|
<span id="crop-size-hint" class="text-muted text-sm"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const BELEG_ID = <?= (int)$beleg['id'] ?>;
|
||||||
|
const FILE_URL = '/belege/<?= (int)$beleg['id'] ?>/file';
|
||||||
|
const overlay = document.getElementById('crop-modal-overlay');
|
||||||
|
const canvas = document.getElementById('crop-canvas');
|
||||||
|
const confirmBtn = document.getElementById('crop-confirm-btn');
|
||||||
|
const sizeHint = document.getElementById('crop-size-hint');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
let img = null;
|
||||||
|
let scaleX = 1, scaleY = 1;
|
||||||
|
let sel = null; // {x,y,w,h} in Canvas-Koordinaten
|
||||||
|
let dragging = false;
|
||||||
|
let startX = 0, startY = 0;
|
||||||
|
|
||||||
|
window.openCropModal = function() {
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
if (img) { drawCanvas(); return; }
|
||||||
|
|
||||||
|
img = new Image();
|
||||||
|
img.onload = function() {
|
||||||
|
// Canvas auf max. 90vw / 72vh skalieren
|
||||||
|
const maxW = Math.min(window.innerWidth * 0.90, 1200);
|
||||||
|
const maxH = Math.min(window.innerHeight * 0.72, 900);
|
||||||
|
const ratio = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1);
|
||||||
|
canvas.width = Math.round(img.naturalWidth * ratio);
|
||||||
|
canvas.height = Math.round(img.naturalHeight * ratio);
|
||||||
|
scaleX = img.naturalWidth / canvas.width;
|
||||||
|
scaleY = img.naturalHeight / canvas.height;
|
||||||
|
sel = null;
|
||||||
|
drawCanvas();
|
||||||
|
};
|
||||||
|
img.src = FILE_URL + '?nocache=' + Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeCropModal = function() {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawCanvas() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
if (!sel || sel.w === 0 || sel.h === 0) return;
|
||||||
|
|
||||||
|
// Abdunkeln außerhalb der Auswahl
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.45)';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Ausschnitt freistellen
|
||||||
|
ctx.drawImage(img,
|
||||||
|
sel.x * scaleX, sel.y * scaleY, sel.w * scaleX, sel.h * scaleY,
|
||||||
|
sel.x, sel.y, sel.w, sel.h);
|
||||||
|
|
||||||
|
// Rahmen
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.setLineDash([5, 3]);
|
||||||
|
ctx.strokeRect(sel.x + 0.5, sel.y + 0.5, sel.w, sel.h);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
const realW = Math.round(sel.w * scaleX);
|
||||||
|
const realH = Math.round(sel.h * scaleY);
|
||||||
|
sizeHint.textContent = realW + ' × ' + realH + ' px';
|
||||||
|
confirmBtn.disabled = (realW < 10 || realH < 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasPos(e) {
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
||||||
|
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(canvas.width, Math.round(cx - r.left))),
|
||||||
|
y: Math.max(0, Math.min(canvas.height, Math.round(cy - r.top))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', startDrag);
|
||||||
|
canvas.addEventListener('touchstart', startDrag, {passive: true});
|
||||||
|
|
||||||
|
function startDrag(e) {
|
||||||
|
const p = canvasPos(e);
|
||||||
|
startX = p.x; startY = p.y;
|
||||||
|
sel = {x: p.x, y: p.y, w: 0, h: 0};
|
||||||
|
dragging = true;
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
sizeHint.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', moveDrag);
|
||||||
|
window.addEventListener('touchmove', moveDrag, {passive: true});
|
||||||
|
|
||||||
|
function moveDrag(e) {
|
||||||
|
if (!dragging) return;
|
||||||
|
const p = canvasPos(e);
|
||||||
|
sel = {
|
||||||
|
x: Math.min(startX, p.x),
|
||||||
|
y: Math.min(startY, p.y),
|
||||||
|
w: Math.abs(p.x - startX),
|
||||||
|
h: Math.abs(p.y - startY),
|
||||||
|
};
|
||||||
|
drawCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', endDrag);
|
||||||
|
window.addEventListener('touchend', endDrag);
|
||||||
|
|
||||||
|
function endDrag() { dragging = false; }
|
||||||
|
|
||||||
|
window.confirmCrop = async function() {
|
||||||
|
if (!sel || sel.w < 1 || sel.h < 1) return;
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
confirmBtn.textContent = 'Wird gespeichert…';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
x: Math.round(sel.x * scaleX),
|
||||||
|
y: Math.round(sel.y * scaleY),
|
||||||
|
w: Math.round(sel.w * scaleX),
|
||||||
|
h: Math.round(sel.h * scaleY),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/belege/' + BELEG_ID + '/crop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Unbekannter Fehler');
|
||||||
|
location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Fehler beim Zuschneiden: ' + err.message);
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
confirmBtn.textContent = 'Zuschnitt übernehmen';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ESC schließt Modal
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && overlay.style.display !== 'none') closeCropModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Klick auf Overlay-Hintergrund schließt
|
||||||
|
overlay.addEventListener('click', function(e) {
|
||||||
|
if (e.target === overlay) closeCropModal();
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (!$beleg['locked']): ?>
|
<?php if (!$beleg['locked']): ?>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue