feat: all AI formats printable, crop-vs-border rule, mobile layout, Passbilder rename
Print now offers every format the AI pipeline knows (9x13 to 60x90, DIN A6-A2, squares, poster/frame sizes to 70x100, and the screen ratios as physical sizes). When a picture does not match the target ratio the user picks per image between cropping to fill and keeping the whole picture on a border colour - never a stretch. The module is called Passbilder now; /druck redirects. Fixes two real defects: sharp runs extend after resize, so padded cells came out oversized (a 35x45 mm cell became 35x171 mm with a border), and the grid packer rotated a single portrait photo just because more would fit sideways. Mobile: cards become rows, touch targets ~40px, crop editor as a bottom sheet. Verified at 390/820/1440px - all three produce the same PDF.
This commit is contained in:
17 files changed
+416
-90
No files matched your search
+125
-24
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string };
|
||||
interface CropRel { x: number; y: number; w: number; h: number }
|
||||
type FitMode = 'cover' | 'contain';
|
||||
|
||||
interface Cell {
|
||||
id: string;
|
||||
@@ -27,21 +28,49 @@ interface Cell {
|
||||
landscape: boolean;
|
||||
count: number;
|
||||
allowRotate: boolean;
|
||||
fit: FitMode; // passt das Bild nicht: zuschneiden oder einpassen
|
||||
bg: string; // Randfarbe beim Einpassen
|
||||
crop: CropRel;
|
||||
}
|
||||
|
||||
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
|
||||
|
||||
/** Größter Ausschnitt mit dem Zielseitenverhältnis, mittig („Bild füllen"). */
|
||||
/** Größter Ausschnitt im Zielverhältnis — füllt das Format aus, schneidet ab. */
|
||||
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 w = imgA > aspect ? aspect / imgA : 1;
|
||||
const h = imgA > aspect ? 1 : imgA / aspect;
|
||||
return clampCrop(centred(w, h, around), 'cover');
|
||||
}
|
||||
|
||||
/** Kleinster Ausschnitt im Zielverhältnis, der das ganze Bild enthält — es bleibt Rand. */
|
||||
function containCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel {
|
||||
const imgA = natW / natH;
|
||||
const w = imgA > aspect ? 1 : aspect / imgA;
|
||||
const h = imgA > aspect ? imgA / aspect : 1;
|
||||
return clampCrop(centred(w, h, around), 'contain');
|
||||
}
|
||||
|
||||
const centred = (w: number, h: number, around?: CropRel): CropRel => {
|
||||
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 });
|
||||
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
||||
};
|
||||
|
||||
/** Basisausschnitt (Zoomstufe 1) für die gewählte Regel. */
|
||||
function baseCrop(natW: number, natH: number, aspect: number, fit: FitMode, around?: CropRel): CropRel {
|
||||
return fit === 'contain' ? containCrop(natW, natH, aspect, around) : coverCrop(natW, natH, aspect, around);
|
||||
}
|
||||
function clampCrop(c: CropRel): CropRel {
|
||||
|
||||
function clampCrop(c: CropRel, fit: FitMode = 'cover'): CropRel {
|
||||
if (fit === 'contain') {
|
||||
// Beim Einpassen darf der Ausschnitt über das Bild hinausragen — dort ist der Rand.
|
||||
const w = Math.min(40, Math.max(0.02, c.w));
|
||||
const h = Math.min(40, Math.max(0.02, c.h));
|
||||
return { w, h,
|
||||
x: Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, c.x)),
|
||||
y: Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, c.y)) };
|
||||
}
|
||||
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)) };
|
||||
@@ -122,7 +151,7 @@ export default function PrintApp() {
|
||||
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)) }
|
||||
crop: baseCrop(probe.naturalWidth, probe.naturalHeight, aspectOf(x), x.fit) }
|
||||
: x));
|
||||
probe.src = url;
|
||||
};
|
||||
@@ -169,7 +198,7 @@ export default function PrintApp() {
|
||||
preview: `/api/items/${it.id}/file?preview=1`,
|
||||
natW: w || 2000, natH: h || 3000,
|
||||
};
|
||||
c.crop = coverCrop(c.natW, c.natH, aspectOf(c));
|
||||
c.crop = baseCrop(c.natW, c.natH, aspectOf(c), c.fit);
|
||||
setCells((p) => [...p, c]);
|
||||
};
|
||||
|
||||
@@ -177,9 +206,9 @@ export default function PrintApp() {
|
||||
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) {
|
||||
if (up.sizeId !== undefined || up.customSize !== undefined || up.landscape !== undefined || up.fit !== undefined) {
|
||||
const a = aspectOf(next);
|
||||
if (a) next.crop = coverCrop(next.natW, next.natH, a, c.crop);
|
||||
if (a) next.crop = baseCrop(next.natW, next.natH, a, next.fit, c.crop);
|
||||
}
|
||||
return next;
|
||||
}));
|
||||
@@ -193,7 +222,8 @@ export default function PrintApp() {
|
||||
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 };
|
||||
return { id: c.id, src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, count: c.count,
|
||||
allowRotate: c.allowRotate, fit: c.fit, bg: c.bg };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -219,7 +249,7 @@ export default function PrintApp() {
|
||||
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 }),
|
||||
body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name, fit: c.fit, bg: c.bg }),
|
||||
});
|
||||
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; }
|
||||
const blob = await res.blob();
|
||||
@@ -232,12 +262,13 @@ export default function PrintApp() {
|
||||
};
|
||||
|
||||
const savePreset = async () => {
|
||||
const name = prompt('Name der Vorlage? (z. B. „Kita-Satz Felix")');
|
||||
const name = prompt('Name der Vorlage? (z. B. „Kita-Satz" oder „8 × Passbild")');
|
||||
if (!name?.trim()) return;
|
||||
const config = {
|
||||
paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm,
|
||||
marks, markLen, markOff, dpi, ext, center, footer, deliverTo, gallery,
|
||||
formats: cells.map((c) => ({ sizeId: c.sizeId, customSize: c.customSize, landscape: c.landscape, count: c.count, allowRotate: c.allowRotate })),
|
||||
formats: cells.map((c) => ({ sizeId: c.sizeId, customSize: c.customSize, landscape: c.landscape,
|
||||
count: c.count, allowRotate: c.allowRotate, fit: c.fit, bg: c.bg })),
|
||||
};
|
||||
await fetch('/api/print/presets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -260,8 +291,9 @@ export default function PrintApp() {
|
||||
if (!prev.length) return prev;
|
||||
const apply = (cell: Cell, f: any): Cell => {
|
||||
const next = { ...cell, id: cell.id, 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);
|
||||
landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false,
|
||||
fit: (f.fit === 'contain' ? 'contain' : 'cover') as FitMode, bg: f.bg || '#ffffff' };
|
||||
const a = aspectOf(next); if (a) next.crop = baseCrop(next.natW, next.natH, a, next.fit, cell.crop);
|
||||
return next;
|
||||
};
|
||||
if (prev.length === 1 && c.formats.length > 1)
|
||||
@@ -477,6 +509,7 @@ export default function PrintApp() {
|
||||
<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}%`,
|
||||
background: c.fit === 'contain' ? c.bg : undefined,
|
||||
}}>
|
||||
<div className="rotor" style={p.rotated ? {
|
||||
width: `${(p.h / p.w) * 100}%`, height: `${(p.w / p.h) * 100}%`,
|
||||
@@ -522,7 +555,7 @@ 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 },
|
||||
fit: 'cover', bg: '#ffffff', crop: { x: 0, y: 0, w: 1, h: 1 },
|
||||
};
|
||||
}
|
||||
function aspectOf(c: Cell): number {
|
||||
@@ -558,7 +591,8 @@ function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
|
||||
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' }}
|
||||
<div className="zelle-bild" style={{ aspectRatio: s ? `${s.w} / ${s.h}` : '3 / 4',
|
||||
...(cell.fit === 'contain' ? { background: cell.bg } : {}) }}
|
||||
onClick={() => !cell.uploading && onEdit()}>
|
||||
{cell.preview
|
||||
? <img src={cell.preview} alt="" style={cropStyle(cell.crop)} />
|
||||
@@ -590,6 +624,22 @@ function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
|
||||
<button onClick={() => onPatch({ count: Math.min(200, cell.count + 1) })}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="schalter winzig voll">
|
||||
<button className={cell.fit === 'cover' ? 'an' : ''} onClick={() => onPatch({ fit: 'cover' })}
|
||||
title="Bild füllt das Format aus, überstehende Ränder werden abgeschnitten">Zuschneiden</button>
|
||||
<button className={cell.fit === 'contain' ? 'an' : ''} onClick={() => onPatch({ fit: 'contain' })}
|
||||
title="Ganzes Bild bleibt sichtbar, außen entsteht ein Rand">Rand lassen</button>
|
||||
</div>
|
||||
{cell.fit === 'contain' && (
|
||||
<div className="randfarbe">
|
||||
<span>Randfarbe</span>
|
||||
{['#ffffff', '#000000', '#EAEAE3'].map((col) => (
|
||||
<button key={col} className={`farbe ${cell.bg.toLowerCase() === col ? 'an' : ''}`}
|
||||
style={{ background: col }} onClick={() => onPatch({ bg: col })} aria-label={col} />
|
||||
))}
|
||||
<input type="color" value={cell.bg} onChange={(e) => onPatch({ bg: e.target.value })} aria-label="Eigene Randfarbe" />
|
||||
</div>
|
||||
)}
|
||||
<label className="check winzig">
|
||||
<input type="checkbox" checked={cell.allowRotate} onChange={(e) => onPatch({ allowRotate: e.target.checked })} />
|
||||
<span>darf gedreht platziert werden</span>
|
||||
@@ -610,7 +660,7 @@ function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
|
||||
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 base = baseCrop(cell.natW, cell.natH, aspect, cell.fit);
|
||||
const [crop, setCrop] = useState<CropRel>(cell.crop);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ x: number; y: number; crop: CropRel } | null>(null);
|
||||
@@ -620,7 +670,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
|
||||
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 }));
|
||||
setCrop(clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h }, cell.fit));
|
||||
};
|
||||
|
||||
const onDown = (e: React.PointerEvent) => {
|
||||
@@ -633,7 +683,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
|
||||
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 }));
|
||||
setCrop(clampCrop({ ...c, x: c.x - dx * c.w, y: c.y - dy * c.h }, cell.fit));
|
||||
};
|
||||
const onUp = () => { drag.current = null; };
|
||||
|
||||
@@ -653,7 +703,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
|
||||
<button className="link" onClick={onClose}>Schließen</button>
|
||||
</div>
|
||||
<div className="crop-buehne">
|
||||
<div ref={boxRef} className="crop-box" style={{ aspectRatio: `${aspect}` }}
|
||||
<div ref={boxRef} className="crop-box" style={{ aspectRatio: `${aspect}`, background: cell.fit === 'contain' ? cell.bg : '#fff' }}
|
||||
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)} />
|
||||
@@ -666,11 +716,12 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
|
||||
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="pbtn" onClick={() => setCrop(base)}>{cell.fit === 'contain' ? 'Ganzes Bild' : 'Bild füllen'}</button>
|
||||
<button className="pbtn" onClick={() => setCrop(clampCrop({ ...crop, x: (1 - crop.w) / 2, y: (1 - crop.h) / 2 }, cell.fit))}>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 className="fein">Ziehen zum Verschieben, Mausrad oder Regler zum Zoomen. Das Seitenverhältnis bleibt exakt am Zielformat — verzerrt wird nie.
|
||||
{cell.fit === 'contain' && ' Beim Einpassen bleibt außen die Randfarbe stehen.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -763,6 +814,11 @@ function PrintStyles() {
|
||||
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
|
||||
.schalter.zart button{font-size:12px;padding:6px 4px;}
|
||||
.schalter.winzig{margin:0;flex:1;}
|
||||
.schalter.winzig.voll{flex:none;width:100%;}
|
||||
.randfarbe{display:flex;align-items:center;gap:5px;font-size:10.5px;color:var(--soft);}
|
||||
.randfarbe .farbe{width:18px;height:18px;border:1px solid var(--line);border-radius:3px;cursor:pointer;padding:0;}
|
||||
.randfarbe .farbe.an{outline:2px solid var(--accent);outline-offset:1px;}
|
||||
.randfarbe input[type=color]{width:24px;height:20px;border:1px solid var(--line);border-radius:3px;background:none;padding:1px;cursor:pointer;}
|
||||
.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%;}
|
||||
@@ -804,5 +860,50 @@ function PrintStyles() {
|
||||
.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;}
|
||||
|
||||
/* ---------- Mobil (Daumenbedienung, kleine Fläche) ---------- */
|
||||
@media (max-width: 720px){
|
||||
.druck{gap:12px;}
|
||||
.buehne,.steuer,.vorschau{padding:12px;}
|
||||
.kopfzeile{padding:11px 12px;}
|
||||
/* Eine Karte je Zeile: die Formatnamen sind lang, zweispaltig wird alles abgeschnitten. */
|
||||
.zellen{grid-template-columns:1fr;gap:10px;}
|
||||
.zelle{flex-direction:row;gap:12px;align-items:flex-start;}
|
||||
.zelle-bild{flex:0 0 34%;max-width:150px;}
|
||||
.zelle-steuer{flex:1;min-width:0;}
|
||||
/* Platz für den Entfernen-Knopf lassen, sonst liegt er auf dem Format-Menü. */
|
||||
.zelle-steuer .mini-select{padding-right:34px;}
|
||||
.zelle-add{min-height:54px;}
|
||||
.zelle{padding:8px;}
|
||||
/* Touch-Ziele: mindestens ~40 px hoch */
|
||||
.schalter button{padding:11px 6px;font-size:13px;}
|
||||
.schalter.winzig button{padding:9px 4px;font-size:12px;}
|
||||
.schalter.zart button{padding:10px 4px;font-size:12px;}
|
||||
.anzahl button{width:36px;height:38px;font-size:18px;}
|
||||
.anzahl input{height:38px;width:40px;}
|
||||
.zelle-x{width:28px;height:28px;font-size:15px;top:4px;right:4px;}
|
||||
.pbtn{padding:11px 12px;}
|
||||
.link{font-size:10.5px;}
|
||||
.kopf-tools{gap:14px;}
|
||||
.input,.select{padding:11px;}
|
||||
.reihe.zwei{gap:10px;}
|
||||
.check input{width:18px;height:18px;}
|
||||
.randfarbe .farbe{width:24px;height:24px;}
|
||||
.randfarbe input[type=color]{width:30px;height:26px;}
|
||||
/* Zuschnitt-Fenster: bildschirmfüllend, Regler und Knöpfe erreichbar */
|
||||
.modal{padding:0;align-items:flex-end;}
|
||||
.modal-inhalt{width:100%;max-height:100vh;border-radius:0;border-left:none;border-right:none;
|
||||
padding-bottom:env(safe-area-inset-bottom);}
|
||||
.crop-buehne{padding:10px;}
|
||||
.crop-box{max-width:100%;max-height:52vh;}
|
||||
.crop-steuer .reihe{flex-wrap:wrap;}
|
||||
.crop-steuer .knopf.schmal{flex:1 1 100%;padding:13px;}
|
||||
.picker{grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;}
|
||||
.toast{bottom:calc(84px + env(safe-area-inset-bottom));}
|
||||
}
|
||||
@media (max-width: 380px){
|
||||
.reihe.zwei{grid-template-columns:1fr;}
|
||||
.zelle-bild{flex-basis:40%;}
|
||||
}
|
||||
`}</style>;
|
||||
}
|
||||
Reference in new issue
Block a user