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 ( } > {fehler ? (

{fehler}

) : ( <>
{bild && } {r && (
starte(e, "move")} onPointerMove={bewege} onPointerUp={beende} onPointerCancel={beende} > {GRIFFE.map((g) => ( starte(e, g.id)} onPointerMove={bewege} onPointerUp={beende} onPointerCancel={beende} /> ))}
)}
{rahmen ? t("abos.cropSize", { breite: Math.round(rahmen.w), hoehe: Math.round(rahmen.h), }) : ""}

{t("abos.cropHint")}

)}
); }