cd9081823d
New Druck tab: upload or pick from the library, choose a target size and copy count per image, pan/zoom crop on a locked aspect ratio, and a live preview that renders the same layout the PDF will contain. Sheet controls for paper, margin, gap, bleed, crop marks, dpi and templates.
769 lines
41 KiB
TypeScript
769 lines
41 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||
import { PAPERS, PHOTO_SIZES, parseSizeMm, labelMm, mmToPx, paperById, photoById } from '../lib/paper';
|
||
import {
|
||
layout, cutMarks, recommendedGap, dpiCheck,
|
||
type SheetSpec, type PlaceSpec, type MarkMode,
|
||
} from '../lib/printlayout';
|
||
|
||
/* --------------------------------------------------------------------------
|
||
Druck — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit
|
||
Schnittmarken auf einen Bogen setzen. Alles rechnet in Millimetern;
|
||
Pixel entstehen erst beim Rendern auf dem Server.
|
||
-------------------------------------------------------------------------- */
|
||
|
||
type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string };
|
||
interface CropRel { x: number; y: number; w: number; h: number }
|
||
|
||
interface Cell {
|
||
id: string;
|
||
name: string;
|
||
preview: string; // Data-URL (Upload) oder /api/items/…/file
|
||
src?: SrcRef;
|
||
uploading?: boolean;
|
||
error?: string;
|
||
natW: number; natH: number; // echte Quellpixel (für den dpi-Hinweis)
|
||
sizeId: string; // Schlüssel aus PHOTO_SIZES oder 'custom'
|
||
customSize: string;
|
||
landscape: boolean;
|
||
count: number;
|
||
allowRotate: boolean;
|
||
crop: CropRel;
|
||
}
|
||
|
||
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
|
||
|
||
/** Größter Ausschnitt mit dem Zielseitenverhältnis, mittig („Bild füllen"). */
|
||
function coverCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel {
|
||
const imgA = natW / natH;
|
||
let w = 1, h = 1;
|
||
if (imgA > aspect) { h = 1; w = (aspect / imgA); } else { w = 1; h = (imgA / aspect); }
|
||
const cx = around ? around.x + around.w / 2 : 0.5;
|
||
const cy = around ? around.y + around.h / 2 : 0.5;
|
||
return clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h });
|
||
}
|
||
function clampCrop(c: CropRel): CropRel {
|
||
const w = Math.min(1, Math.max(0.02, c.w));
|
||
const h = Math.min(1, Math.max(0.02, c.h));
|
||
return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) };
|
||
}
|
||
|
||
const sizeOfCell = (c: Cell): { w: number; h: number } | null => {
|
||
const base = c.sizeId === 'custom' ? parseSizeMm(c.customSize) : photoById(c.sizeId);
|
||
if (!base) return null;
|
||
const s = { w: base.w, h: base.h };
|
||
return c.landscape ? { w: s.h, h: s.w } : s;
|
||
};
|
||
|
||
export default function PrintApp() {
|
||
const [cells, setCells] = useState<Cell[]>([]);
|
||
const [paperId, setPaperId] = useState('A4');
|
||
const [paperCustom, setPaperCustom] = useState('');
|
||
const [paperLandscape, setPaperLandscape] = useState(false);
|
||
const [marginMm, setMarginMm] = useState(5);
|
||
const [autoGap, setAutoGap] = useState(true);
|
||
const [gapMm, setGapMm] = useState(12);
|
||
const [bleedMm, setBleedMm] = useState(0);
|
||
const [marks, setMarks] = useState<MarkMode>('corner');
|
||
const [markLen, setMarkLen] = useState(4);
|
||
const [markOff, setMarkOff] = useState(3);
|
||
const [dpi, setDpi] = useState(300);
|
||
const [ext, setExt] = useState<'jpg' | 'png'>('jpg');
|
||
const [center, setCenter] = useState(true);
|
||
const [footer, setFooter] = useState(true);
|
||
|
||
const [editing, setEditing] = useState<string | null>(null);
|
||
const [picker, setPicker] = useState(false);
|
||
const [presets, setPresets] = useState<any[]>([]);
|
||
const [presetSel, setPresetSel] = useState('');
|
||
const [busy, setBusy] = useState<null | 'pdf' | 'single'>(null);
|
||
const [toast, setToast] = useState<string | null>(null);
|
||
const [dragging, setDragging] = useState(false);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 3000); };
|
||
|
||
/* ---------------- Bogen ---------------- */
|
||
const paper = useMemo(() => {
|
||
const p = paperId === 'custom' ? parseSizeMm(paperCustom) : paperById(paperId);
|
||
if (!p) return null;
|
||
return paperLandscape ? { w: p.h, h: p.w } : { w: p.w, h: p.h };
|
||
}, [paperId, paperCustom, paperLandscape]);
|
||
|
||
const gapEff = autoGap ? Math.max(recommendedGap(marks, bleedMm, markLen, markOff), 2) : gapMm;
|
||
|
||
const sheet: SheetSpec | null = paper
|
||
? { wMm: paper.w, hMm: paper.h, marginMm, gapMm: gapEff, bleedMm, center }
|
||
: null;
|
||
|
||
const specs: PlaceSpec[] = useMemo(() => cells.flatMap((c) => {
|
||
const s = sizeOfCell(c);
|
||
if (!s || c.error || !c.src) return [];
|
||
return [{ id: c.id, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate }];
|
||
}), [cells]);
|
||
|
||
const plan = useMemo(() => (sheet && specs.length ? layout(specs, sheet) : null), [sheet, specs]);
|
||
const marksPerPage = useMemo(() => (plan && sheet)
|
||
? plan.pages.map((pg) => cutMarks(pg, sheet, { mode: marks, lengthMm: markLen, offsetMm: markOff, bleedMm }))
|
||
: [], [plan, sheet, marks, markLen, markOff, bleedMm]);
|
||
|
||
/* ---------------- Upload ---------------- */
|
||
const addFiles = useCallback(async (files: FileList | File[]) => {
|
||
const arr = Array.from(files)
|
||
.filter((f) => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name))
|
||
.slice(0, 30);
|
||
for (const f of arr) {
|
||
const id = uid();
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const url = reader.result as string;
|
||
const probe = new Image();
|
||
probe.onload = () => setCells((p) => p.map((x) => x.id === id
|
||
? { ...x, preview: url, natW: probe.naturalWidth, natH: probe.naturalHeight,
|
||
crop: coverCrop(probe.naturalWidth, probe.naturalHeight, aspectOf(x)) }
|
||
: x));
|
||
probe.src = url;
|
||
};
|
||
reader.readAsDataURL(f);
|
||
setCells((p) => [...p, newCell(id, f.name)]);
|
||
const fd = new FormData(); fd.append('files', f);
|
||
try {
|
||
const j = await fetch('/api/uploads', { method: 'POST', body: fd }).then((r) => r.json());
|
||
const info = j.files?.[0];
|
||
setCells((p) => p.map((x) => x.id === id ? {
|
||
...x, uploading: false, error: info?.error,
|
||
src: info?.source_path ? { kind: 'upload', path: info.source_path } : undefined,
|
||
natW: info?.width || x.natW, natH: info?.height || x.natH,
|
||
} : x));
|
||
} catch {
|
||
setCells((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: 'Upload fehlgeschlagen' } : x));
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const onPaste = (e: ClipboardEvent) => {
|
||
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/'));
|
||
if (imgs.length) addFiles(imgs.map((i) => i.getAsFile()!).filter(Boolean));
|
||
};
|
||
window.addEventListener('paste', onPaste);
|
||
return () => window.removeEventListener('paste', onPaste);
|
||
}, [addFiles]);
|
||
|
||
useEffect(() => { loadPresets(); }, []);
|
||
const loadPresets = () => fetch('/api/print/presets').then((r) => r.json())
|
||
.then((j) => setPresets(j.presets || [])).catch(() => {});
|
||
|
||
const addFromLibrary = (it: any) => {
|
||
const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0);
|
||
const id = uid();
|
||
const c: Cell = {
|
||
...newCell(id, it.filename || 'Bild'),
|
||
uploading: false,
|
||
src: { kind: 'item', id: it.id },
|
||
preview: `/api/items/${it.id}/file?preview=1`,
|
||
natW: w || 2000, natH: h || 3000,
|
||
};
|
||
c.crop = coverCrop(c.natW, c.natH, aspectOf(c));
|
||
setCells((p) => [...p, c]);
|
||
};
|
||
|
||
const patch = (id: string, up: Partial<Cell>) => setCells((p) => p.map((c) => {
|
||
if (c.id !== id) return c;
|
||
const next = { ...c, ...up };
|
||
// Formatwechsel → Ausschnitt auf das neue Seitenverhältnis nachziehen.
|
||
if (up.sizeId !== undefined || up.customSize !== undefined || up.landscape !== undefined) {
|
||
const a = aspectOf(next);
|
||
if (a) next.crop = coverCrop(next.natW, next.natH, a, c.crop);
|
||
}
|
||
return next;
|
||
}));
|
||
|
||
/* ---------------- Ausgabe ---------------- */
|
||
const body = () => ({
|
||
paper: paperId === 'custom' ? { size: paperCustom } : { id: paperId },
|
||
landscape: paperLandscape,
|
||
marginMm, gapMm: gapEff, bleedMm, center, dpi, ext, footer,
|
||
marks: { mode: marks, lengthMm: markLen, offsetMm: markOff },
|
||
cells: cells.filter((c) => c.src && !c.error && sizeOfCell(c)).map((c) => {
|
||
const s = sizeOfCell(c)!;
|
||
return { id: c.id, src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate };
|
||
}),
|
||
});
|
||
|
||
const makePdf = async () => {
|
||
if (!plan?.pages.length) return;
|
||
setBusy('pdf');
|
||
try {
|
||
const res = await fetch('/api/print/sheet', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body()),
|
||
});
|
||
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; }
|
||
const blob = await res.blob();
|
||
download(blob, `klarbild-druckbogen-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||
notify(`PDF erzeugt · ${res.headers.get('X-Klarbild-Pages') || '?'} Bogen. Beim Drucken „Tatsächliche Größe / 100 %" wählen.`);
|
||
} catch { notify('Netzwerkfehler.'); } finally { setBusy(null); }
|
||
};
|
||
|
||
const makeSingle = async (c: Cell) => {
|
||
const s = sizeOfCell(c); if (!s || !c.src) return;
|
||
setBusy('single');
|
||
try {
|
||
const res = await fetch('/api/print/single', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name }),
|
||
});
|
||
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; }
|
||
const blob = await res.blob();
|
||
download(blob, `${(c.name || 'bild').replace(/\.[^.]+$/, '')}_${labelMm(s.w, s.h).replace(/[^0-9]+/g, 'x')}.${ext}`);
|
||
const real = res.headers.get('X-Klarbild-Real-Dpi');
|
||
notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0'
|
||
? `Heruntergeladen — Achtung: die Quelle reicht nur für ca. ${real} dpi.`
|
||
: `Heruntergeladen · ${res.headers.get('X-Klarbild-Px')} px bei ${dpi} dpi.`);
|
||
} catch { notify('Netzwerkfehler.'); } finally { setBusy(null); }
|
||
};
|
||
|
||
const savePreset = async () => {
|
||
const name = prompt('Name der Vorlage? (z. B. „Kita-Satz Felix")');
|
||
if (!name?.trim()) return;
|
||
const config = {
|
||
paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm,
|
||
marks, markLen, markOff, dpi, ext, center, footer,
|
||
formats: cells.map((c) => ({ sizeId: c.sizeId, customSize: c.customSize, landscape: c.landscape, count: c.count, allowRotate: c.allowRotate })),
|
||
};
|
||
await fetch('/api/print/presets', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: name.trim(), config }),
|
||
});
|
||
notify('Vorlage gespeichert.'); loadPresets();
|
||
};
|
||
|
||
const applyPreset = (p: any) => {
|
||
const c = p?.config; if (!c) return;
|
||
setPaperId(c.paperId ?? 'A4'); setPaperCustom(c.paperCustom ?? ''); setPaperLandscape(!!c.paperLandscape);
|
||
setMarginMm(c.marginMm ?? 5); setAutoGap(c.autoGap !== false); setGapMm(c.gapMm ?? 12);
|
||
setBleedMm(c.bleedMm ?? 0); setMarks(c.marks ?? 'corner'); setMarkLen(c.markLen ?? 4); setMarkOff(c.markOff ?? 3);
|
||
setDpi(c.dpi ?? 300); setExt(c.ext === 'png' ? 'png' : 'jpg'); setCenter(c.center !== false); setFooter(c.footer !== false);
|
||
// Formate auf die vorhandenen Bilder anwenden (der Reihe nach).
|
||
if (Array.isArray(c.formats) && c.formats.length) {
|
||
setCells((prev) => prev.map((cell, i) => {
|
||
const f = c.formats[Math.min(i, c.formats.length - 1)];
|
||
const next = { ...cell, sizeId: f.sizeId, customSize: f.customSize || '', landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false };
|
||
const a = aspectOf(next); if (a) next.crop = coverCrop(next.natW, next.natH, a, cell.crop);
|
||
return next;
|
||
}));
|
||
}
|
||
notify('Vorlage geladen.');
|
||
};
|
||
|
||
const deletePreset = async (id: string) => {
|
||
if (!id || !confirm('Vorlage löschen?')) return;
|
||
await fetch(`/api/print/presets?id=${id}`, { method: 'DELETE' });
|
||
setPresetSel(''); loadPresets();
|
||
};
|
||
|
||
const editCell = cells.find((c) => c.id === editing) || null;
|
||
const ready = cells.filter((c) => c.src && !c.error && sizeOfCell(c));
|
||
const totalPieces = ready.reduce((n, c) => n + c.count, 0);
|
||
|
||
return (
|
||
<div className="druck">
|
||
{/* ---------- 1 · Bilder ---------- */}
|
||
<section className="karte">
|
||
<div className="kopfzeile">
|
||
<span className="mono-label">1 · Bilder</span>
|
||
<div className="kopf-tools">
|
||
<button className="link" onClick={() => setPicker(true)}>Aus Bibliothek</button>
|
||
{cells.length > 0 && <button className="link" onClick={() => setCells([])}>Alle entfernen</button>}
|
||
</div>
|
||
</div>
|
||
<div className="buehne">
|
||
{cells.length === 0 ? (
|
||
<div className={`drop ${dragging ? 'drag' : ''}`}
|
||
onClick={() => fileRef.current?.click()}
|
||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||
onDragLeave={() => setDragging(false)}
|
||
onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }}>
|
||
<span className="regmark" />
|
||
<p>Bilder hierher ziehen, <span className="kbd">⌘V</span> einfügen oder klicken.<br />
|
||
Kein KI-Schritt — nur zuschneiden, skalieren, setzen.</p>
|
||
</div>
|
||
) : (
|
||
<div className="zellen">
|
||
{cells.map((c) => <CellCard key={c.id} cell={c} dpi={dpi}
|
||
onPatch={(u) => patch(c.id, u)}
|
||
onEdit={() => setEditing(c.id)}
|
||
onSingle={() => makeSingle(c)}
|
||
onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} />)}
|
||
<button className="zelle-add" onClick={() => fileRef.current?.click()}>+ Bild</button>
|
||
</div>
|
||
)}
|
||
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
|
||
onChange={(e) => e.target.files && addFiles(e.target.files)} />
|
||
</div>
|
||
</section>
|
||
|
||
<div className="raster">
|
||
{/* ---------- 2 · Bogen ---------- */}
|
||
<section className="karte">
|
||
<div className="kopfzeile"><span className="mono-label">2 · Bogen & Schnitt</span>
|
||
<button className="link" onClick={savePreset}>Als Vorlage sichern</button>
|
||
</div>
|
||
<div className="steuer">
|
||
<div className="feld">
|
||
<label>Vorlagen</label>
|
||
<div className="reihe">
|
||
<select className="select" value={presetSel}
|
||
onChange={(e) => { setPresetSel(e.target.value); applyPreset(presets.find((p) => p.id === e.target.value)); }}>
|
||
<option value="">Vorlage wählen …</option>
|
||
{presets.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
{presetSel && <button className="pbtn" onClick={() => deletePreset(presetSel)}>✕</button>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="feld">
|
||
<label>Papierformat</label>
|
||
<select className="select" value={paperId} onChange={(e) => setPaperId(e.target.value)}>
|
||
{groupBy(PAPERS, 'group').map(([g, list]) => (
|
||
<optgroup key={g} label={g}>
|
||
{list.map((p) => <option key={p.id} value={p.id}>{p.label} · {p.w} × {p.h} mm</option>)}
|
||
</optgroup>
|
||
))}
|
||
<option value="custom">Eigenes Papiermaß …</option>
|
||
</select>
|
||
{paperId === 'custom' && (
|
||
<input className="input" value={paperCustom} onChange={(e) => setPaperCustom(e.target.value)}
|
||
placeholder="z. B. 32,9x48,3 (cm) oder 329x483mm" />
|
||
)}
|
||
<div className="schalter">
|
||
<button className={!paperLandscape ? 'an' : ''} onClick={() => setPaperLandscape(false)}>Hoch</button>
|
||
<button className={paperLandscape ? 'an' : ''} onClick={() => setPaperLandscape(true)}>Quer</button>
|
||
</div>
|
||
{paper && <div className="fein">Bogen {labelMm(paper.w, paper.h)} — die PDF-Seite hat exakt dieses Maß.</div>}
|
||
</div>
|
||
|
||
<div className="feld">
|
||
<label>Schnitthilfen</label>
|
||
<div className="schalter">
|
||
<button className={marks === 'none' ? 'an' : ''} onClick={() => setMarks('none')}>Keine</button>
|
||
<button className={marks === 'corner' ? 'an' : ''} onClick={() => setMarks('corner')}>Eckmarken</button>
|
||
<button className={marks === 'grid' ? 'an' : ''} onClick={() => setMarks('grid')}>Durchgehend</button>
|
||
</div>
|
||
<div className="fein">
|
||
{marks === 'corner' ? 'Feine Marken an den vier Ecken jedes Bildes, außerhalb des Endformats — wie in Photoshop/InDesign.'
|
||
: marks === 'grid' ? 'Durchgehende Hilfslinien über den ganzen Bogen — für Schneidelineal und Schlagschere.'
|
||
: 'Keine Linien — Kanten selbst anlegen.'}
|
||
</div>
|
||
{marks === 'corner' && (
|
||
<div className="reihe zwei">
|
||
<label className="mini">Länge (mm)
|
||
<input className="input" type="number" min={1} max={20} step={0.5} value={markLen}
|
||
onChange={(e) => setMarkLen(Number(e.target.value))} /></label>
|
||
<label className="mini">Abstand (mm)
|
||
<input className="input" type="number" min={0} max={20} step={0.5} value={markOff}
|
||
onChange={(e) => setMarkOff(Number(e.target.value))} /></label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="feld">
|
||
<label>Ränder & Abstände</label>
|
||
<div className="reihe zwei">
|
||
<label className="mini">Rand zum Papier (mm)
|
||
<input className="input" type="number" min={0} max={40} step={0.5} value={marginMm}
|
||
onChange={(e) => setMarginMm(Number(e.target.value))} /></label>
|
||
<label className="mini">Beschnittzugabe (mm)
|
||
<input className="input" type="number" min={0} max={10} step={0.5} value={bleedMm}
|
||
onChange={(e) => setBleedMm(Number(e.target.value))} /></label>
|
||
</div>
|
||
<label className="check">
|
||
<input type="checkbox" checked={autoGap} onChange={(e) => setAutoGap(e.target.checked)} />
|
||
<span>Abstand automatisch ({gapEff.toFixed(1)} mm — passend zu den Marken)</span>
|
||
</label>
|
||
{!autoGap && (
|
||
<input className="input" type="number" min={0} max={60} step={0.5} value={gapMm}
|
||
onChange={(e) => setGapMm(Number(e.target.value))} />
|
||
)}
|
||
<label className="check">
|
||
<input type="checkbox" checked={center} onChange={(e) => setCenter(e.target.checked)} />
|
||
<span>Auf dem Bogen zentrieren</span>
|
||
</label>
|
||
<div className="fein">Randlos druckbare Drucker: Rand 0 setzen. Sonst 3–5 mm — sonst schneidet der Drucker Marken weg.</div>
|
||
</div>
|
||
|
||
<div className="feld">
|
||
<label>Qualität</label>
|
||
<div className="reihe zwei">
|
||
<label className="mini">Auflösung (dpi)
|
||
<select className="select" value={dpi} onChange={(e) => setDpi(Number(e.target.value))}>
|
||
<option value={150}>150 — Entwurf</option>
|
||
<option value={300}>300 — Fotodruck (Standard)</option>
|
||
<option value={600}>600 — sehr fein</option>
|
||
</select></label>
|
||
<label className="mini">Dateiformat im PDF
|
||
<select className="select" value={ext} onChange={(e) => setExt(e.target.value as any)}>
|
||
<option value="jpg">JPG — kleiner</option>
|
||
<option value="png">PNG — verlustfrei</option>
|
||
</select></label>
|
||
</div>
|
||
<label className="check">
|
||
<input type="checkbox" checked={footer} onChange={(e) => setFooter(e.target.checked)} />
|
||
<span>Fußzeile mit Maßen & Druckhinweis</span>
|
||
</label>
|
||
</div>
|
||
|
||
<button className="knopf" disabled={!plan?.pages.length || busy === 'pdf'} onClick={makePdf}>
|
||
{busy === 'pdf' ? <><span className="spin" />PDF wird gebaut …</> : 'Druck-PDF erzeugen'}
|
||
</button>
|
||
<div className="fein mitte">Im Druckdialog <b>„Tatsächliche Größe" / 100 %</b> wählen — nicht „an Seitengröße anpassen".</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ---------- 3 · Vorschau ---------- */}
|
||
<section className="karte">
|
||
<div className="kopfzeile">
|
||
<span className="mono-label">3 · Vorschau</span>
|
||
{plan && <span className="fein nopad">{plan.pages.length} Bogen · {totalPieces} Bilder</span>}
|
||
</div>
|
||
<div className="vorschau">
|
||
{!sheet ? <p className="leer">Bitte ein gültiges Papiermaß angeben.</p>
|
||
: !plan || !plan.pages.length ? <p className="leer">Noch nichts zu zeigen — Bilder hinzufügen und Formate wählen.</p>
|
||
: plan.pages.map((pg, i) => (
|
||
<div key={i} className="bogen-halter">
|
||
<div className="bogen" style={{ aspectRatio: `${sheet.wMm} / ${sheet.hMm}` }}>
|
||
{pg.placements.map((p, k) => {
|
||
const c = cells.find((x) => x.id === p.specId);
|
||
if (!c) return null;
|
||
return (
|
||
<div key={k} className="slot" style={{
|
||
left: `${(p.x / sheet.wMm) * 100}%`, top: `${(p.y / sheet.hMm) * 100}%`,
|
||
width: `${(p.w / sheet.wMm) * 100}%`, height: `${(p.h / sheet.hMm) * 100}%`,
|
||
}}>
|
||
<div className="rotor" style={p.rotated ? {
|
||
width: `${(p.h / p.w) * 100}%`, height: `${(p.w / p.h) * 100}%`,
|
||
transform: 'translate(-50%, -50%) rotate(90deg)',
|
||
} : undefined}>
|
||
<img src={c.preview} alt="" style={cropStyle(c.crop)} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
<svg className="marken" viewBox={`0 0 ${sheet.wMm} ${sheet.hMm}`} preserveAspectRatio="none">
|
||
{(marksPerPage[i] || []).map((l, k) => (
|
||
<line key={k} x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2}
|
||
stroke="#16150F" strokeWidth={0.25} vectorEffect="non-scaling-stroke" />
|
||
))}
|
||
</svg>
|
||
</div>
|
||
<div className="bogen-fuss">Bogen {i + 1} · {pg.placements.length} Bilder</div>
|
||
</div>
|
||
))}
|
||
{plan?.unplaced?.length ? (
|
||
<p className="warnung">Nicht platzierbar: {plan.unplaced.map((u) => {
|
||
const c = cells.find((x) => x.id === u.specId);
|
||
return `${c?.name || u.specId} (${u.reason})`;
|
||
}).join(', ')}</p>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{editCell && <CropModal cell={editCell} onClose={() => setEditing(null)}
|
||
onChange={(crop) => patch(editCell.id, { crop })} />}
|
||
{picker && <LibraryPicker onClose={() => setPicker(false)} onPick={(it) => { addFromLibrary(it); setPicker(false); }} />}
|
||
{toast && <div className="toast">{toast}</div>}
|
||
<PrintStyles />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------------- Bausteine ---------------- */
|
||
|
||
function newCell(id: string, name: string): Cell {
|
||
return {
|
||
id, name, preview: '', uploading: true, natW: 1000, natH: 1500,
|
||
sizeId: 'S10x15', customSize: '', landscape: false, count: 1, allowRotate: true,
|
||
crop: { x: 0, y: 0, w: 1, h: 1 },
|
||
};
|
||
}
|
||
function aspectOf(c: Cell): number {
|
||
const s = sizeOfCell(c);
|
||
return s ? s.w / s.h : 1;
|
||
}
|
||
function cropStyle(c: CropRel): React.CSSProperties {
|
||
return {
|
||
position: 'absolute',
|
||
width: `${100 / c.w}%`, height: `${100 / c.h}%`,
|
||
left: `${(-c.x / c.w) * 100}%`, top: `${(-c.y / c.h) * 100}%`,
|
||
objectFit: 'fill',
|
||
};
|
||
}
|
||
function groupBy<T extends { group: string }>(list: T[], _k: 'group'): [string, T[]][] {
|
||
const m = new Map<string, T[]>();
|
||
for (const x of list) { if (!m.has(x.group)) m.set(x.group, []); m.get(x.group)!.push(x); }
|
||
return [...m.entries()];
|
||
}
|
||
function download(blob: Blob, name: string) {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||
}
|
||
|
||
function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
|
||
cell: Cell; dpi: number;
|
||
onPatch: (u: Partial<Cell>) => void; onEdit: () => void; onSingle: () => void; onRemove: () => void;
|
||
}) {
|
||
const s = sizeOfCell(cell);
|
||
const check = s ? dpiCheck(cell.crop.w * cell.natW, s.w, dpi) : null;
|
||
return (
|
||
<div className={`zelle ${cell.error ? 'err' : ''}`}>
|
||
<button className="zelle-x" onClick={onRemove} aria-label="Entfernen">✕</button>
|
||
<div className="zelle-bild" style={{ aspectRatio: s ? `${s.w} / ${s.h}` : '3 / 4' }}
|
||
onClick={() => !cell.uploading && onEdit()}>
|
||
{cell.preview
|
||
? <img src={cell.preview} alt="" style={cropStyle(cell.crop)} />
|
||
: <span className="lade" />}
|
||
{!cell.uploading && <span className="zelle-lupe">Zuschnitt</span>}
|
||
</div>
|
||
<div className="zelle-steuer">
|
||
<select className="select mini-select" value={cell.sizeId} onChange={(e) => onPatch({ sizeId: e.target.value })}>
|
||
{groupBy(PHOTO_SIZES, 'group').map(([g, list]) => (
|
||
<optgroup key={g} label={g}>
|
||
{list.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
||
</optgroup>
|
||
))}
|
||
<option value="custom">Eigenes Maß …</option>
|
||
</select>
|
||
{cell.sizeId === 'custom' && (
|
||
<input className="input mini-input" value={cell.customSize} onChange={(e) => onPatch({ customSize: e.target.value })}
|
||
placeholder="12x15 · 35x45mm · 4:3/15" />
|
||
)}
|
||
<div className="zelle-reihe">
|
||
<div className="schalter winzig">
|
||
<button className={!cell.landscape ? 'an' : ''} onClick={() => onPatch({ landscape: false })}>Hoch</button>
|
||
<button className={cell.landscape ? 'an' : ''} onClick={() => onPatch({ landscape: true })}>Quer</button>
|
||
</div>
|
||
<div className="anzahl">
|
||
<button onClick={() => onPatch({ count: Math.max(1, cell.count - 1) })}>−</button>
|
||
<input type="number" min={1} max={200} value={cell.count}
|
||
onChange={(e) => onPatch({ count: Math.min(200, Math.max(1, Number(e.target.value) || 1)) })} />
|
||
<button onClick={() => onPatch({ count: Math.min(200, cell.count + 1) })}>+</button>
|
||
</div>
|
||
</div>
|
||
<label className="check winzig">
|
||
<input type="checkbox" checked={cell.allowRotate} onChange={(e) => onPatch({ allowRotate: e.target.checked })} />
|
||
<span>darf gedreht platziert werden</span>
|
||
</label>
|
||
{cell.error ? <div className="fein warn">{cell.error}</div> : s && (
|
||
<div className={`fein ${check && !check.ok ? 'warn' : ''}`}>
|
||
{labelMm(s.w, s.h)} = {mmToPx(s.w, dpi)} × {mmToPx(s.h, dpi)} px
|
||
{check && (check.ok ? ' · Auflösung reicht' : ` · nur ca. ${check.dpi} dpi`)}
|
||
</div>
|
||
)}
|
||
<button className="pbtn breit" disabled={!cell.src} onClick={onSingle}>Einzeln herunterladen</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Zuschnitt: schieben und zoomen, Seitenverhältnis bleibt fest am Zielformat. */
|
||
function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: CropRel) => void; onClose: () => void }) {
|
||
const s = sizeOfCell(cell);
|
||
const aspect = s ? s.w / s.h : 1;
|
||
const base = coverCrop(cell.natW, cell.natH, aspect);
|
||
const [crop, setCrop] = useState<CropRel>(cell.crop);
|
||
const boxRef = useRef<HTMLDivElement>(null);
|
||
const drag = useRef<{ x: number; y: number; crop: CropRel } | null>(null);
|
||
|
||
const zoom = Math.min(20, Math.max(1, base.w / crop.w));
|
||
const setZoom = (z: number) => {
|
||
const zz = Math.min(20, Math.max(1, z));
|
||
const cx = crop.x + crop.w / 2, cy = crop.y + crop.h / 2;
|
||
const w = base.w / zz, h = base.h / zz;
|
||
setCrop(clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h }));
|
||
};
|
||
|
||
const onDown = (e: React.PointerEvent) => {
|
||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||
drag.current = { x: e.clientX, y: e.clientY, crop };
|
||
};
|
||
const onMove = (e: React.PointerEvent) => {
|
||
if (!drag.current || !boxRef.current) return;
|
||
const r = boxRef.current.getBoundingClientRect();
|
||
const dx = (e.clientX - drag.current.x) / r.width;
|
||
const dy = (e.clientY - drag.current.y) / r.height;
|
||
const c = drag.current.crop;
|
||
setCrop(clampCrop({ ...c, x: c.x - dx * c.w, y: c.y - dy * c.h }));
|
||
};
|
||
const onUp = () => { drag.current = null; };
|
||
|
||
useEffect(() => {
|
||
const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||
window.addEventListener('keydown', esc);
|
||
return () => window.removeEventListener('keydown', esc);
|
||
}, [onClose]);
|
||
|
||
const apply = () => { onChange(crop); onClose(); };
|
||
|
||
return (
|
||
<div className="modal" onClick={onClose}>
|
||
<div className="modal-inhalt" onClick={(e) => e.stopPropagation()}>
|
||
<div className="kopfzeile">
|
||
<span className="mono-label">Zuschnitt · {s ? labelMm(s.w, s.h) : ''}</span>
|
||
<button className="link" onClick={onClose}>Schließen</button>
|
||
</div>
|
||
<div className="crop-buehne">
|
||
<div ref={boxRef} className="crop-box" style={{ aspectRatio: `${aspect}` }}
|
||
onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
|
||
onWheel={(e) => { setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}>
|
||
<img src={cell.preview} alt="" draggable={false} style={cropStyle(crop)} />
|
||
<div className="crop-gitter" />
|
||
</div>
|
||
</div>
|
||
<div className="crop-steuer">
|
||
<label className="mini breit">Zoom
|
||
<input type="range" min={1} max={6} step={0.01} value={Math.min(6, zoom)}
|
||
onChange={(e) => setZoom(Number(e.target.value))} />
|
||
</label>
|
||
<div className="reihe">
|
||
<button className="pbtn" onClick={() => setCrop(base)}>Bild füllen</button>
|
||
<button className="pbtn" onClick={() => setCrop(clampCrop({ ...crop, x: (1 - crop.w) / 2, y: (1 - crop.h) / 2 }))}>Zentrieren</button>
|
||
<button className="knopf schmal" onClick={apply}>Übernehmen</button>
|
||
</div>
|
||
<div className="fein">Ziehen zum Verschieben, Mausrad oder Regler zum Zoomen. Das Seitenverhältnis bleibt exakt am Zielformat.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LibraryPicker({ onPick, onClose }: { onPick: (it: any) => void; onClose: () => void }) {
|
||
const [items, setItems] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
useEffect(() => {
|
||
fetch('/api/items').then((r) => r.json())
|
||
.then((j) => setItems(j.items || [])).catch(() => {}).finally(() => setLoading(false));
|
||
}, []);
|
||
return (
|
||
<div className="modal" onClick={onClose}>
|
||
<div className="modal-inhalt breit" onClick={(e) => e.stopPropagation()}>
|
||
<div className="kopfzeile">
|
||
<span className="mono-label">Aus der Bibliothek wählen</span>
|
||
<button className="link" onClick={onClose}>Schließen</button>
|
||
</div>
|
||
<div className="picker">
|
||
{loading ? <p className="leer">Wird geladen …</p>
|
||
: !items.length ? <p className="leer">Noch keine Bilder in der Bibliothek.</p>
|
||
: items.map((it) => (
|
||
<button key={it.id} className="pick" onClick={() => onPick(it)} title={it.filename}>
|
||
<img src={`/api/items/${it.id}/file?thumb=1`} alt="" loading="lazy" />
|
||
<span>{it.filename}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PrintStyles() {
|
||
return <style>{`
|
||
.druck{display:flex;flex-direction:column;gap:16px;}
|
||
.raster{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px;align-items:start;}
|
||
@media(max-width:900px){.raster{grid-template-columns:1fr;}}
|
||
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line);}
|
||
.kopf-tools{display:flex;gap:12px;}
|
||
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;padding:0;}
|
||
.buehne{padding:16px;}
|
||
.drop{width:100%;min-height:180px;border:1.5px dashed var(--line);border-radius:var(--radius-sm);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:26px;cursor:pointer;}
|
||
.drop.drag{border-color:var(--accent);background:var(--accent-bg);}
|
||
.drop p{margin:0;color:var(--soft);font-size:14px;line-height:1.55;}
|
||
.kbd{font-family:var(--font-mono);font-size:12px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 6px;}
|
||
|
||
.zellen{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px;}
|
||
.zelle{position:relative;border:1px solid var(--line);border-radius:var(--radius-sm);background:#fff;padding:9px;display:flex;flex-direction:column;gap:8px;}
|
||
.zelle.err{border-color:var(--err);}
|
||
.zelle-x{position:absolute;top:5px;right:5px;z-index:3;width:22px;height:22px;border:none;border-radius:50%;background:rgba(22,21,15,.72);color:#fff;cursor:pointer;font-size:13px;line-height:1;}
|
||
.zelle-bild{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:3px;cursor:zoom-in;
|
||
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/14px 14px;}
|
||
.zelle-bild img{display:block;}
|
||
.zelle-lupe{position:absolute;left:0;right:0;bottom:0;background:rgba(22,21,15,.62);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;text-align:center;padding:3px 0;}
|
||
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
|
||
.zelle-steuer{display:flex;flex-direction:column;gap:6px;}
|
||
.zelle-reihe{display:flex;gap:6px;align-items:center;}
|
||
.zelle-add{border:1.5px dashed var(--line);background:none;border-radius:var(--radius-sm);cursor:pointer;font-size:14px;color:var(--soft);min-height:120px;font-family:inherit;}
|
||
|
||
.anzahl{display:flex;align-items:center;border:1px solid var(--line);border-radius:3px;overflow:hidden;background:#fff;}
|
||
.anzahl button{width:28px;height:30px;border:none;background:none;cursor:pointer;font-size:15px;color:var(--ink);}
|
||
.anzahl input{width:44px;height:30px;border:none;text-align:center;font-family:var(--font-mono);font-size:13px;outline:none;-moz-appearance:textfield;}
|
||
.anzahl input::-webkit-outer-spin-button,.anzahl input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0;}
|
||
|
||
.steuer{padding:16px;display:flex;flex-direction:column;gap:15px;}
|
||
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
|
||
.input,.select{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
|
||
.mini-select,.mini-input{padding:6px 8px;font-size:12.5px;}
|
||
.input:focus,.select:focus{border-color:var(--accent);}
|
||
.reihe{display:flex;gap:6px;align-items:center;margin-top:6px;}
|
||
.reihe .select{flex:1;}
|
||
.reihe.zwei{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
|
||
.mini{display:block;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--soft);}
|
||
.mini .input,.mini .select{margin-top:4px;}
|
||
.mini.breit{width:100%;}
|
||
.mini input[type=range]{width:100%;accent-color:var(--accent);margin-top:6px;}
|
||
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
|
||
.fein.nopad{margin:0;}
|
||
.fein.mitte{text-align:center;}
|
||
.fein.warn{color:var(--err);}
|
||
.check{display:flex;gap:8px;align-items:flex-start;margin-top:8px;font-size:12.5px;color:var(--ink);text-transform:none;letter-spacing:0;font-family:inherit;}
|
||
.check input{width:15px;height:15px;accent-color:var(--accent);margin-top:2px;flex:0 0 auto;}
|
||
.check.winzig{font-size:11px;color:var(--soft);}
|
||
.schalter{display:flex;gap:6px;margin-top:8px;}
|
||
.schalter button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);}
|
||
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
|
||
.schalter.winzig{margin:0;flex:1;}
|
||
.schalter.winzig button{padding:5px 4px;font-size:11.5px;}
|
||
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
|
||
.pbtn.breit{width:100%;}
|
||
.pbtn:disabled{opacity:.5;cursor:not-allowed;}
|
||
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||
.knopf.schmal{width:auto;padding:9px 16px;font-size:13.5px;}
|
||
.knopf:disabled{background:#C9C8BF;cursor:not-allowed;}
|
||
.spin{width:13px;height:13px;display:inline-block;border:2px solid #fff;border-top-color:transparent;border-radius:50%;vertical-align:-2px;margin-right:9px;animation:sp 1s linear infinite;}
|
||
@keyframes sp{to{transform:rotate(360deg);}}
|
||
|
||
.vorschau{padding:16px;display:flex;flex-direction:column;gap:16px;}
|
||
.leer{color:var(--soft);font-size:13px;margin:0;text-align:center;padding:26px 0;}
|
||
.bogen-halter{display:flex;flex-direction:column;gap:6px;}
|
||
.bogen{position:relative;width:100%;background:#fff;border:1px solid var(--line);box-shadow:var(--shadow);overflow:hidden;}
|
||
.slot{position:absolute;overflow:hidden;}
|
||
.rotor{position:absolute;left:50%;top:50%;width:100%;height:100%;transform:translate(-50%,-50%);overflow:hidden;}
|
||
.rotor img{position:absolute;}
|
||
.marken{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;}
|
||
.bogen-fuss{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);}
|
||
.warnung{color:var(--err);font-size:12px;margin:0;}
|
||
|
||
.modal{position:fixed;inset:0;background:rgba(22,21,15,.55);display:flex;align-items:center;justify-content:center;z-index:70;padding:16px;}
|
||
.modal-inhalt{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);width:min(560px,100%);max-height:90vh;overflow:auto;}
|
||
.modal-inhalt.breit{width:min(880px,100%);}
|
||
.crop-buehne{padding:16px;display:flex;justify-content:center;background:var(--paper);}
|
||
.crop-box{position:relative;width:100%;max-width:420px;max-height:56vh;overflow:hidden;background:#fff;border:1px solid var(--line);cursor:grab;touch-action:none;user-select:none;}
|
||
.crop-box:active{cursor:grabbing;}
|
||
.crop-box img{position:absolute;pointer-events:none;}
|
||
.crop-gitter{position:absolute;inset:0;pointer-events:none;
|
||
background:linear-gradient(to right,transparent calc(33.33% - .5px),rgba(255,255,255,.55) 33.33%,rgba(255,255,255,.55) calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),rgba(255,255,255,.55) 66.66%,rgba(255,255,255,.55) calc(66.66% + .5px),transparent calc(66.66% + .5px)),
|
||
linear-gradient(to bottom,transparent calc(33.33% - .5px),rgba(255,255,255,.55) 33.33%,rgba(255,255,255,.55) calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),rgba(255,255,255,.55) 66.66%,rgba(255,255,255,.55) calc(66.66% + .5px),transparent calc(66.66% + .5px));
|
||
box-shadow:inset 0 0 0 1px rgba(27,59,224,.35);}
|
||
.crop-steuer{padding:14px 16px;display:flex;flex-direction:column;gap:8px;}
|
||
|
||
.picker{padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px;}
|
||
.pick{border:1px solid var(--line);background:#fff;border-radius:3px;padding:0;cursor:pointer;overflow:hidden;display:flex;flex-direction:column;font-family:inherit;}
|
||
.pick img{width:100%;aspect-ratio:1;object-fit:cover;display:block;}
|
||
.pick span{font-size:10px;color:var(--soft);padding:4px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.pick:hover{border-color:var(--accent);}
|
||
|
||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:80;max-width:min(92vw,520px);text-align:center;line-height:1.45;}
|
||
`}</style>;
|
||
}
|