Files
certain/web/src/components/LogoZuschnitt.jsx
T
ScarriffleandClaude Opus 5 26718b1301 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>
2026-09-07 19:59:11 +02:00

241 lines
8.3 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
// 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;
// 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;
// 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: ein Rahmen, den man verschiebt und an den
* Ecken aufzieht.
*
* 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 [busy, setBusy] = useState(false);
// Ausschnitt in Bildpunkten des Originals.
const [rahmen, setRahmen] = useState(null);
const flaeche = useRef(null);
const zug = useRef(null);
// Das `weg`-Zeichen ist kein Zierrat: React ruft den Effekt im
// 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 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) 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 () => {
weg = true;
URL.revokeObjectURL(url);
};
}, [datei, t]);
// 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));
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 {
w, h,
x: Math.min(Math.max(r.x, 0), maxB - w),
y: Math.min(Math.max(r.y, 0), maxH - h),
};
}
function starte(e, griff) {
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
zug.current = { griff, x: e.clientX, y: e.clientY, start: rahmen, ziel: e.currentTarget };
}
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 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 || !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 = 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";
g.drawImage(
bild,
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"));
onFertig(new File([blob], "logo.png", { type: "image/png" }));
} catch (e) {
setFehler(e.message);
} finally {
setBusy(false);
}
}
// 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
title={t("abos.cropTitle")}
onClose={onAbbrechen}
footer={
<>
<button type="button" className="btn" onClick={onAbbrechen}>
{t("common.cancel")}
</button>
<button type="button" className="btn primary" disabled={!bild || busy}
onClick={uebernehmen}>
{t("abos.cropApply")}
</button>
</>
}
>
{fehler ? (
<p className="muted warn">{fehler}</p>
) : (
<>
<div
ref={flaeche}
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} />}
{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>
<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>
);
}