diff --git a/web/src/components/LogoZuschnitt.jsx b/web/src/components/LogoZuschnitt.jsx new file mode 100644 index 0000000..f68f22e --- /dev/null +++ b/web/src/components/LogoZuschnitt.jsx @@ -0,0 +1,186 @@ +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; + +// 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; + +// Wie weit man hineinzoomen darf, gemessen am eingepassten Bild. +const MAX_ZOOM = 6; + +/** + * Ausschnitt eines Bildes wählen: ziehen zum Verschieben, Regler zum Zoomen. + * + * 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. + */ +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); + const flaeche = useRef(null); + const ziehen = 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 + // noch ladenden Bild seine Adresse weg – dessen `onerror` meldete dann + // "kein lesbares Bild", obwohl der zweite Lauf laengst 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.onerror = () => { if (!weg) setFehler(t("abos.cropUnreadable")); }; + img.src = url; + return () => { + weg = true; + URL.revokeObjectURL(url); + }; + }, [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; + + // 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); + return { + x: Math.min(maxX, Math.max(-maxX, v.x)), + y: Math.min(maxY, Math.max(-maxY, v.y)), + }; + } + + useEffect(() => { setVersatz((v) => begrenze(v)); /* eslint-disable-next-line */ }, [zoom, bild]); + + function beiDown(e) { + if (breite <= RAHMEN && hoehe <= RAHMEN) return; + e.currentTarget.setPointerCapture(e.pointerId); + ziehen.current = { x: e.clientX, y: e.clientY, start: versatz }; + } + 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 beiUp(e) { + if (ziehen.current) e.currentTarget.releasePointerCapture(e.pointerId); + ziehen.current = null; + } + + async function uebernehmen() { + if (!bild) return; + setBusy(true); + try { + const leinwand = document.createElement("canvas"); + leinwand.width = AUSGABE; + leinwand.height = AUSGABE; + 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, + ); + 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); + } + } + + const beweglich = breite > RAHMEN || hoehe > RAHMEN; + + return ( + + + + + } + > + {fehler ? ( +

{fehler}

+ ) : ( + <> +
+ {bild && ( + + )} +
+ + +

+ {beweglich ? t("abos.cropHintMove") : t("abos.cropHint")} +

+ + )} +
+ ); +} diff --git a/web/src/finance.css b/web/src/finance.css index 7c78d54..a05d617 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1771,3 +1771,41 @@ tr.row-dup { /* Das Dateifeld steckt in einem