feat: Der Zuschnitt ist ein Rahmen, den man aufzieht

Zoomen war das falsche Werkzeug. Man konnte nur naeher heran, aber nicht
sagen, WAS man behalten will – und das Ergebnis war immer quadratisch, auch
wenn das Logo breit ist.

Jetzt liegt ein Rahmen auf dem Bild: ziehen zum Verschieben, acht Griffe zum
Aufziehen. Aussen abgedunkelt, Drittel-Linien zum Ausrichten, und darunter
steht, wie gross der Ausschnitt gerade ist. "Ganzes Bild" setzt zurueck.

Das Seitenverhaeltnis des Ausschnitts bleibt. Ein breites Logo wird ein
breites Logo, kein quadratisches mit Luft an den Seiten.

Der Ausschnitt wird in BILDPUNKTEN gefuehrt, nicht in Bildschirmpunkten –
umgerechnet wird nur fuer die Anzeige. So trifft das Ergebnis genau den
Bereich im Rahmen, ohne dass sich ueber viele Zieh-Bewegungen Rundungsfehler
aufaddieren.

Am Bildrand haelt die gegenueberliegende Kante still, statt dass der Rahmen
beim Anschlag mitwandert; zieht man einen Griff ueber die eigene Kante
hinaus, bleibt sie stehen, statt sich umzustuelpen. Auf dem Telefon sind die
Griffe groesser – mit dem Finger waeren vierzehn Pixel nicht zu treffen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 19:59:11 +02:00
co-authored by Claude Opus 5
parent 9b9ce77267
commit 26718b1301
4 changed files with 223 additions and 117 deletions
+145 -91
View File
@@ -1,49 +1,64 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
import Icon from "./Icon";
// Kantenlänge des Rahmens auf dem Schirm. Klein genug fürs Telefon, gross
// genug, dass man sieht, was man abschneidet.
const RAHMEN = 260;
// Wie gross das Bild im Dialog höchstens dargestellt wird. Passt in den
// schmalen Dialog und auf ein Telefon.
const FLAECHE_B = 320;
const FLAECHE_H = 280;
// Kantenlänge des Ergebnisses. Doppelt so gross wie das, was der Server
// speichert (128) – so hat sein Verkleinern noch Reserve und das Logo wird
// auf einem dichten Bildschirm nicht unscharf.
const AUSGABE = 256;
// Längste Kante des Ergebnisses. Der Server rechnet auf 128 herunter; mit
// Reserve bleibt das Logo auf einem dichten Bildschirm scharf.
const AUSGABE_MAX = 512;
// Wie weit man hineinzoomen darf, gemessen am eingepassten Bild.
const MAX_ZOOM = 6;
// Kleinster Ausschnitt in Bildschirm-Punkten. Kleiner liesse er sich nicht
// mehr greifen.
const MIN_ANZEIGE = 24;
// Die acht Griffe. `x`/`y`: welche Kante der Griff bewegt (-1 links/oben,
// +1 rechts/unten, 0 gar nicht).
const GRIFFE = [
{ id: "nw", x: -1, y: -1 }, { id: "n", x: 0, y: -1 }, { id: "ne", x: 1, y: -1 },
{ id: "w", x: -1, y: 0 }, { id: "e", x: 1, y: 0 },
{ id: "sw", x: -1, y: 1 }, { id: "s", x: 0, y: 1 }, { id: "se", x: 1, y: 1 },
];
/**
* Ausschnitt eines Bildes wählen: ziehen zum Verschieben, Regler zum Zoomen.
* Ausschnitt eines Bildes wählen: ein Rahmen, den man verschiebt und an den
* Ecken aufzieht.
*
* Der Anfangszustand zeigt das GANZE Bild im Rahmen. Damit ändert sich für
* niemanden etwas, der gar nicht zuschneiden will – er drückt Übernehmen und
* bekommt, was er hochgeladen hat. Zuschneiden ist ein Zoom, kein Zwang.
* Der Ausschnitt wird in BILDPUNKTEN geführt, nicht in Bildschirmpunkten.
* Umgerechnet wird nur für die Anzeige – so ist das Ergebnis genau der
* Bereich, den der Rahmen umschliesst, ohne Rundungsdrift über viele
* Zieh-Bewegungen hinweg.
*
* Der Rahmen steht anfangs um das ganze Bild. Wer nicht zuschneiden will,
* drückt Übernehmen und bekommt, was er hochgeladen hat.
*/
export default function LogoZuschnitt({ datei, onAbbrechen, onFertig }) {
const { t } = useTranslation();
const [bild, setBild] = useState(null);
const [fehler, setFehler] = useState(null);
const [zoom, setZoom] = useState(1);
const [versatz, setVersatz] = useState({ x: 0, y: 0 });
const [busy, setBusy] = useState(false);
// Ausschnitt in Bildpunkten des Originals.
const [rahmen, setRahmen] = useState(null);
const flaeche = useRef(null);
const ziehen = useRef(null);
const zug = useRef(null);
// Bild einlesen. Die Objekt-Adresse wieder freigeben, sonst hält der Browser
// die Datei bis zum Neuladen der Seite fest.
// Das `weg`-Zeichen ist kein Zierrat: React ruft den Effekt im
// Entwicklungsmodus zweimal auf. Der Aufraeumer des ersten Laufs zieht dem
// Entwicklungsmodus zweimal auf. Der Aufräumer des ersten Laufs zieht dem
// noch ladenden Bild seine Adresse weg – dessen `onerror` meldete dann
// "kein lesbares Bild", obwohl der zweite Lauf laengst geladen hatte.
// "kein lesbares Bild", obwohl der zweite Lauf längst geladen hatte.
useEffect(() => {
if (!datei) return undefined;
let weg = false;
const url = URL.createObjectURL(datei);
const img = new Image();
img.onload = () => { if (!weg) setBild(img); };
img.onload = () => {
if (weg) return;
setBild(img);
setRahmen({ x: 0, y: 0, w: img.naturalWidth, h: img.naturalHeight });
};
img.onerror = () => { if (!weg) setFehler(t("abos.cropUnreadable")); };
img.src = url;
return () => {
@@ -52,63 +67,93 @@ export default function LogoZuschnitt({ datei, onAbbrechen, onFertig }) {
};
}, [datei, t]);
// Massstab, bei dem das ganze Bild in den Rahmen passt.
const passt = bild ? Math.min(RAHMEN / bild.naturalWidth, RAHMEN / bild.naturalHeight) : 1;
const massstab = passt * zoom;
const breite = bild ? bild.naturalWidth * massstab : 0;
const hoehe = bild ? bild.naturalHeight * massstab : 0;
// Anzeigegrösse: das ganze Bild, eingepasst.
const skala = bild
? Math.min(FLAECHE_B / bild.naturalWidth, FLAECHE_H / bild.naturalHeight, 1)
: 1;
const zeigB = bild ? Math.round(bild.naturalWidth * skala) : 0;
const zeigH = bild ? Math.round(bild.naturalHeight * skala) : 0;
const minBild = Math.max(8, Math.round(MIN_ANZEIGE / skala));
// Verschieben nur, solange das Bild den Rahmen überragt – sonst schöbe man
// ein kleines Bild aus seinem eigenen Ausschnitt heraus.
function begrenze(v) {
const maxX = Math.max(0, (breite - RAHMEN) / 2);
const maxY = Math.max(0, (hoehe - RAHMEN) / 2);
function begrenze(r) {
if (!bild) return r;
const maxB = bild.naturalWidth;
const maxH = bild.naturalHeight;
const w = Math.min(Math.max(r.w, minBild), maxB);
const h = Math.min(Math.max(r.h, minBild), maxH);
return {
x: Math.min(maxX, Math.max(-maxX, v.x)),
y: Math.min(maxY, Math.max(-maxY, v.y)),
w, h,
x: Math.min(Math.max(r.x, 0), maxB - w),
y: Math.min(Math.max(r.y, 0), maxH - h),
};
}
useEffect(() => { setVersatz((v) => begrenze(v)); /* eslint-disable-next-line */ }, [zoom, bild]);
function beiDown(e) {
if (breite <= RAHMEN && hoehe <= RAHMEN) return;
function starte(e, griff) {
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
ziehen.current = { x: e.clientX, y: e.clientY, start: versatz };
zug.current = { griff, x: e.clientX, y: e.clientY, start: rahmen, ziel: e.currentTarget };
}
function beiMove(e) {
if (!ziehen.current) return;
const d = ziehen.current;
setVersatz(begrenze({
x: d.start.x + (e.clientX - d.x),
y: d.start.y + (e.clientY - d.y),
}));
function bewege(e) {
const z = zug.current;
if (!z || !bild) return;
// Bildschirm-Weg in Bildpunkte umrechnen – der Rahmen lebt im Original.
const dx = (e.clientX - z.x) / skala;
const dy = (e.clientY - z.y) / skala;
const s = z.start;
if (z.griff === "move") {
setRahmen(begrenze({ ...s, x: s.x + dx, y: s.y + dy }));
return;
}
const g = GRIFFE.find((k) => k.id === z.griff);
let { x, y, w, h } = s;
if (g.x === -1) { x = s.x + dx; w = s.w - dx; }
if (g.x === 1) { w = s.w + dx; }
if (g.y === -1) { y = s.y + dy; h = s.h - dy; }
if (g.y === 1) { h = s.h + dy; }
// Beim Ziehen über die eigene Kante hinaus: die Kante bleibt stehen,
// statt dass der Rahmen sich umstülpt.
if (w < minBild) { x = g.x === -1 ? s.x + s.w - minBild : x; w = minBild; }
if (h < minBild) { y = g.y === -1 ? s.y + s.h - minBild : y; h = minBild; }
// Nicht über den Bildrand hinaus – und dabei die gegenüberliegende Kante
// festhalten, sonst wandert der Rahmen beim Anschlag mit.
if (x < 0) { w += x; x = 0; }
if (y < 0) { h += y; y = 0; }
if (x + w > bild.naturalWidth) w = bild.naturalWidth - x;
if (y + h > bild.naturalHeight) h = bild.naturalHeight - y;
setRahmen(begrenze({ x, y, w, h }));
}
function beiUp(e) {
if (ziehen.current) e.currentTarget.releasePointerCapture(e.pointerId);
ziehen.current = null;
function beende(e) {
if (zug.current) {
try { zug.current.ziel.releasePointerCapture(e.pointerId); } catch { /* schon weg */ }
}
zug.current = null;
}
function alles() {
if (bild) setRahmen({ x: 0, y: 0, w: bild.naturalWidth, h: bild.naturalHeight });
}
async function uebernehmen() {
if (!bild) return;
if (!bild || !rahmen) return;
setBusy(true);
try {
// Seitenverhältnis des Ausschnitts behalten, längste Kante begrenzen.
const f = Math.min(AUSGABE_MAX / Math.max(rahmen.w, rahmen.h), 1);
const leinwand = document.createElement("canvas");
leinwand.width = AUSGABE;
leinwand.height = AUSGABE;
leinwand.width = Math.max(1, Math.round(rahmen.w * f));
leinwand.height = Math.max(1, Math.round(rahmen.h * f));
const g = leinwand.getContext("2d");
// Nichts füllen: Der Grund bleibt durchsichtig. Ein weisser Kasten
// fiele auf dunklem Hintergrund sofort auf.
g.imageSmoothingQuality = "high";
// Dieselbe Rechnung wie auf dem Schirm, nur auf die Ausgabegrösse
// hochgezogen. So ist das Ergebnis genau der gezeigte Ausschnitt.
const f = AUSGABE / RAHMEN;
g.drawImage(
bild,
(AUSGABE - breite * f) / 2 + versatz.x * f,
(AUSGABE - hoehe * f) / 2 + versatz.y * f,
breite * f,
hoehe * f,
rahmen.x, rahmen.y, rahmen.w, rahmen.h,
0, 0, leinwand.width, leinwand.height,
);
const blob = await new Promise((r) => leinwand.toBlob(r, "image/png"));
if (!blob) throw new Error(t("abos.cropFailed"));
@@ -120,7 +165,10 @@ export default function LogoZuschnitt({ datei, onAbbrechen, onFertig }) {
}
}
const beweglich = breite > RAHMEN || hoehe > RAHMEN;
// Der Rahmen in Bildschirmpunkten.
const r = rahmen
? { l: rahmen.x * skala, o: rahmen.y * skala, b: rahmen.w * skala, h: rahmen.h * skala }
: null;
return (
<Modal
@@ -144,41 +192,47 @@ export default function LogoZuschnitt({ datei, onAbbrechen, onFertig }) {
<>
<div
ref={flaeche}
className={`zuschnitt-rahmen${beweglich ? " beweglich" : ""}`}
style={{ width: RAHMEN, height: RAHMEN }}
onPointerDown={beiDown}
onPointerMove={beiMove}
onPointerUp={beiUp}
onPointerCancel={beiUp}
className="zuschnitt-flaeche"
style={{ width: zeigB || FLAECHE_B, height: zeigH || FLAECHE_H }}
onPointerMove={bewege}
onPointerUp={beende}
onPointerCancel={beende}
>
{bild && (
<img
src={bild.src}
alt=""
draggable={false}
style={{
width: breite,
height: hoehe,
transform: `translate(${versatz.x}px, ${versatz.y}px)`,
}}
/>
{bild && <img src={bild.src} alt="" draggable={false} />}
{r && (
<div
className="zuschnitt-rahmen"
style={{ left: r.l, top: r.o, width: r.b, height: r.h }}
onPointerDown={(e) => starte(e, "move")}
onPointerMove={bewege}
onPointerUp={beende}
onPointerCancel={beende}
>
{GRIFFE.map((g) => (
<span
key={g.id}
className={`zuschnitt-griff griff-${g.id}`}
onPointerDown={(e) => starte(e, g.id)}
onPointerMove={bewege}
onPointerUp={beende}
onPointerCancel={beende}
/>
))}
</div>
)}
</div>
<label className="zuschnitt-regler">
<Icon name="search" size={15} />
<input
type="range"
min="1"
max={MAX_ZOOM}
step="0.02"
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
/>
</label>
<p className="muted small">
{beweglich ? t("abos.cropHintMove") : t("abos.cropHint")}
</p>
<div className="zuschnitt-fuss">
<span className="muted small">
{rahmen ? t("abos.cropSize", {
breite: Math.round(rahmen.w), hoehe: Math.round(rahmen.h),
}) : ""}
</span>
<button type="button" className="btn sm ghost" onClick={alles}>
{t("abos.cropAll")}
</button>
</div>
<p className="muted small">{t("abos.cropHint")}</p>
</>
)}
</Modal>