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:
13 files changed
+259
-69
No files matched your search
+63
-15
@@ -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;}
|
||||
|
||||
Reference in new issue
Block a user