feat: rename to Drucken, 86 image sizes, fix the distorted crop editor

The crop frame had width:100% together with max-height, so the height was
clamped while the width stayed - the frame lost the target aspect ratio and
object-fit:fill stretched the picture into it. Measured at 390px: frame 0.843
instead of 0.778, picture 8.3% too wide. The width now follows from the allowed
height and the ratio, and the image carries its own aspect-ratio instead of a
second percentage, so it cannot stretch at all.

Dragging was fine but felt dead: at fill zoom the crop sits at the stop in one
axis. The editor now states which way it can move, double-tap zooms, arrow keys
nudge, and the zoom slider shows its factor.

Sizes: 41 -> 86 in ten groups, including US inch sizes, instant-film picture
areas, cards, DIN A0-A7 and eleven aspect ratios as physical sizes.

The tab is called Drucken now; /druck and /passbilder redirect there.
This commit is contained in:
till committed 2026-08-19 07:50:44 +00:00
1 parent 7289cde03a
commit 210a84dcf8
13 files changed
+259 -69

No files matched your search

+63 -15
View File
@@ -527,7 +527,7 @@ export default function PrintApp() {
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)} />
<img src={c.preview} alt="" style={cropStyle(c.crop, c.natW, c.natH)} />
</div>
</div>
);
@@ -574,12 +574,20 @@ function aspectOf(c: Cell): number {
const s = sizeOfCell(c);
return s ? s.w / s.h : 1;
}
function cropStyle(c: CropRel): React.CSSProperties {
/**
* Platziert das Bild so im Rahmen, dass genau `c` sichtbar ist.
* Die Höhe kommt aus dem **echten** Seitenverhältnis des Bildes (`aspect-ratio`),
* nicht aus einem zweiten Prozentwert — dadurch kann das Bild nie verzerren,
* auch wenn der Rahmen einmal nicht exakt das Zielverhältnis hat.
*/
function cropStyle(c: CropRel, natW?: number, natH?: number): React.CSSProperties {
return {
position: 'absolute',
width: `${100 / c.w}%`, height: `${100 / c.h}%`,
width: `${100 / c.w}%`,
...(natW && natH
? { aspectRatio: `${natW} / ${natH}`, height: 'auto' as const }
: { 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[]][] {
@@ -607,7 +615,7 @@ function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
...(cell.fit === 'contain' ? { background: cell.bg } : {}) }}
onClick={() => !cell.uploading && onEdit()}>
{cell.preview
? <img src={cell.preview} alt="" style={cropStyle(cell.crop)} />
? <img src={cell.preview} alt="" style={cropStyle(cell.crop, cell.natW, cell.natH)} />
: <span className="lade" />}
{!cell.uploading && <span className="zelle-lupe">Zuschnitt</span>}
</div>
@@ -699,11 +707,34 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
};
const onUp = () => { drag.current = null; };
// Wohin lässt sich das Bild überhaupt schieben? Bei „Bild füllen" liegt es in
// einer Achse exakt am Anschlag — dort passiert beim Ziehen nichts, und das
// sieht wie ein Fehler aus, wenn man es nicht dazuschreibt.
const freiX = crop.w < 1 - 1e-4 || cell.fit === 'contain';
const freiY = crop.h < 1 - 1e-4 || cell.fit === 'contain';
const richtung = freiX && freiY ? 'in alle Richtungen verschiebbar'
: freiX ? 'nach links und rechts verschiebbar — zum Verschieben nach oben/unten erst hineinzoomen'
: freiY ? 'nach oben und unten verschiebbar — zum Verschieben nach links/rechts erst hineinzoomen'
: 'genau passend — zum Verschieben erst hineinzoomen';
useEffect(() => {
const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', esc);
return () => window.removeEventListener('keydown', esc);
}, [onClose]);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') return onClose();
const step = e.shiftKey ? 0.05 : 0.01;
const move = (dx: number, dy: number) => {
e.preventDefault();
setCrop((c) => clampCrop({ ...c, x: c.x + dx * c.w, y: c.y + dy * c.h }, cell.fit));
};
if (e.key === 'ArrowLeft') move(-step, 0);
else if (e.key === 'ArrowRight') move(step, 0);
else if (e.key === 'ArrowUp') move(0, -step);
else if (e.key === 'ArrowDown') move(0, step);
else if (e.key === '+' || e.key === '=') { e.preventDefault(); setZoom(zoom * 1.15); }
else if (e.key === '-') { e.preventDefault(); setZoom(zoom / 1.15); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, cell.fit, zoom]);
const apply = () => { onChange(crop); onClose(); };
@@ -715,24 +746,31 @@ 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}`, background: cell.fit === 'contain' ? cell.bg : '#fff' }}
<div ref={boxRef} className="crop-box"
style={{ ['--ar' as any]: aspect, background: cell.fit === 'contain' ? cell.bg : '#fff' }}
onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
onDoubleClick={() => setZoom(zoom >= 2.5 ? 1 : zoom * 1.8)}
onWheel={(e) => { setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}>
<img src={cell.preview} alt="" draggable={false} style={cropStyle(crop)} />
<img src={cell.preview} alt="" draggable={false} style={cropStyle(crop, cell.natW, cell.natH)} />
<div className="crop-gitter" />
</div>
</div>
<div className="crop-steuer">
<label className="mini breit">Zoom
<label className="mini breit">Zoom · {zoom.toFixed(1)}×
<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={`schieb ${freiX || freiY ? '' : 'fest'}`}>
<span className="schieb-pfeile" aria-hidden="true">{freiX && freiY ? '✥' : freiX ? '↔' : freiY ? '↕' : '⊙'}</span>
{richtung}
</div>
<div className="reihe">
<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 — verzerrt wird nie.
<div className="fein">Bild anfassen und ziehen · Doppeltipp oder Mausrad zoomt · Pfeiltasten für Feinarbeit
(mit Umschalt größere Schritte). Das Seitenverhältnis bleibt exakt am Zielformat — verzerrt wird nie.
{cell.fit === 'contain' && ' Beim Einpassen bleibt außen die Randfarbe stehen.'}</div>
</div>
</div>
@@ -856,7 +894,12 @@ function PrintStyles() {
.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;}
/* Höhe NICHT klemmen — sonst bleibt die Breite bei 100 % stehen und der Rahmen
bekommt ein anderes Verhältnis als das Zielformat (das Bild wirkt verzerrt).
Stattdessen die Breite aus der erlaubten Höhe und dem Verhältnis rechnen. */
.crop-box{--croph:56vh;position:relative;width:100%;max-width:min(420px, calc(var(--croph) * var(--ar)));
aspect-ratio:var(--ar);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;
@@ -864,6 +907,11 @@ function PrintStyles() {
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;}
.schieb{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--ink);background:var(--accent-bg);
border-left:3px solid var(--accent);padding:7px 10px;border-radius:3px;line-height:1.45;}
.schieb.fest{background:var(--paper);border-left-color:var(--line);color:var(--soft);}
.schieb-pfeile{font-size:15px;line-height:1;color:var(--accent);}
.schieb.fest .schieb-pfeile{color:var(--soft);}
.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;}
@@ -907,7 +955,7 @@ function PrintStyles() {
.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-box{--croph:48vh;max-width:min(100%, calc(var(--croph) * var(--ar)));}
.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;}