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>
241 lines
8.3 KiB
React
241 lines
8.3 KiB
React
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>
|
||
);
|
||
}
|