feat: Das Logo laesst sich zuschneiden

Ein Screenshot von einer Website ist selten schon das Logo – da ist Rand
drumherum oder drei Marken nebeneinander. Nach dem Waehlen kommt deshalb ein
Rahmen: ziehen zum Verschieben, Regler zum Zoomen.

Der Rahmen IST der Ausschnitt. Deshalb nichts abgedunkelt und keine
Hilfslinien darin – man soll das Ergebnis sehen und nicht eine Vorschau
davon, die man erst uebersetzen muss.

Zu Beginn steht das GANZE Bild im Rahmen. Wer nicht zuschneiden will, drueckt
Uebernehmen und bekommt, was er hochgeladen hat; Zuschneiden ist ein Zoom,
kein Zwang. Verschieben geht nur, solange das Bild den Rahmen ueberragt –
sonst schoebe man ein kleines Bild aus seinem eigenen Ausschnitt heraus.

Geschnitten wird im Browser, hochgeladen nur noch der Ausschnitt: Der Server
bekommt kein Bild, das er gar nicht behalten soll.

Der durchsichtige Grund bleibt durchsichtig – ein weisser Kasten fiele auf
dunklem Hintergrund sofort auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 19:50:16 +02:00
co-authored by Claude Opus 5
parent dd2c33725f
commit 9b9ce77267
5 changed files with 259 additions and 5 deletions
+186
View File
@@ -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 (
<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-rahmen${beweglich ? " beweglich" : ""}`}
style={{ width: RAHMEN, height: RAHMEN }}
onPointerDown={beiDown}
onPointerMove={beiMove}
onPointerUp={beiUp}
onPointerCancel={beiUp}
>
{bild && (
<img
src={bild.src}
alt=""
draggable={false}
style={{
width: breite,
height: hoehe,
transform: `translate(${versatz.x}px, ${versatz.y}px)`,
}}
/>
)}
</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>
</>
)}
</Modal>
);
}
+38
View File
@@ -1771,3 +1771,41 @@ tr.row-dup {
/* Das Dateifeld steckt in einem <label>, das wie ein Knopf aussieht. */
.abo-logo-knoepfe label.btn { cursor: pointer; margin: 0; }
.abo-logo-knoepfe label.btn.deaktiviert { opacity: 0.5; cursor: not-allowed; }
/* --- Logo zuschneiden ---------------------------------------------------- */
/* Der Rahmen IST der Ausschnitt: Was hier steht, wird gespeichert. Deshalb
nichts abdunkeln und keine Hilfslinien – man soll das Ergebnis sehen, nicht
eine Vorschau davon. */
.zuschnitt-rahmen {
position: relative;
overflow: hidden;
margin: 0 auto var(--sp-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background:
repeating-conic-gradient(var(--border) 0% 25%, transparent 0% 50%) 50% / 16px 16px;
touch-action: none;
user-select: none;
}
.zuschnitt-rahmen.beweglich { cursor: grab; }
.zuschnitt-rahmen.beweglich:active { cursor: grabbing; }
.zuschnitt-rahmen img {
position: absolute;
left: 50%;
top: 50%;
margin-left: -50%;
margin-top: 0;
translate: 0 -50%;
max-width: none;
pointer-events: none;
}
.zuschnitt-regler {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
.zuschnitt-regler input[type="range"] { flex: 1; width: auto; }
.zuschnitt-regler .icon { color: var(--muted); flex: none; }
+7 -1
View File
@@ -371,7 +371,13 @@
"logoUpload": "Bild wählen",
"logoRemove": "Entfernen",
"logoHint": "PNG, JPEG, WEBP oder GIF. Wird auf Kachelgrösse gerechnet – lad ruhig das grosse Bild hoch. Solange kein Logo da ist, gilt das Symbol unten.",
"logoAfterSave": "Ein Logo lässt sich anhängen, sobald der Vertrag gespeichert ist. Bis dahin gilt das Symbol unten."
"logoAfterSave": "Ein Logo lässt sich anhängen, sobald der Vertrag gespeichert ist. Bis dahin gilt das Symbol unten.",
"cropTitle": "Ausschnitt wählen",
"cropApply": "Übernehmen",
"cropHint": "Zoom den Regler auf, um einen Ausschnitt zu wählen. So wie es hier steht, wird es gespeichert.",
"cropHintMove": "Zum Verschieben ziehen. So wie es hier steht, wird es gespeichert.",
"cropUnreadable": "Das ist kein lesbares Bild. PNG, JPEG, WEBP oder GIF gehen.",
"cropFailed": "Der Ausschnitt liess sich nicht erzeugen."
},
"import": {
"title": "Import",
+7 -1
View File
@@ -371,7 +371,13 @@
"logoUpload": "Choose image",
"logoRemove": "Remove",
"logoHint": "PNG, JPEG, WEBP or GIF. It is scaled down for you – upload the large one. While there is no logo, the icon below is used.",
"logoAfterSave": "A logo can be attached once the contract is saved. Until then the icon below is used."
"logoAfterSave": "A logo can be attached once the contract is saved. Until then the icon below is used.",
"cropTitle": "Choose the crop",
"cropApply": "Use this",
"cropHint": "Zoom in to pick a section. What you see here is what gets saved.",
"cropHintMove": "Drag to move. What you see here is what gets saved.",
"cropUnreadable": "That is not a readable image. PNG, JPEG, WEBP or GIF work.",
"cropFailed": "The crop could not be created."
},
"import": {
"title": "Import",
+21 -3
View File
@@ -12,6 +12,7 @@ import CategorySelect from "../components/CategorySelect";
import AccountSelect from "../components/AccountSelect";
import { gruppenFuer } from "../kategorien";
import CurrencySelect from "../components/CurrencySelect";
import LogoZuschnitt from "../components/LogoZuschnitt";
import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format";
// Muss zu INTERVALL_MONATE im Server passen.
@@ -50,6 +51,8 @@ export default function Abos() {
const [form, setForm] = useState(null); // Formularzustand oder null
const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false);
// Die gewaehlte Datei, solange der Ausschnitt noch nicht bestaetigt ist.
const [zuschnitt, setZuschnitt] = useState(null);
async function laden() {
try {
@@ -136,8 +139,15 @@ export default function Abos() {
}
}
async function logoWaehlen(datei) {
if (!datei || !editing) return;
// Nicht sofort hochladen: Erst waehlt man den Ausschnitt. Was danach zum
// Server geht, ist genau das, was im Rahmen stand.
function logoWaehlen(datei) {
if (datei && editing) setZuschnitt(datei);
}
async function logoHochladen(datei) {
setZuschnitt(null);
if (!editing) return;
setBusy(true);
try {
const aktualisiert = await api.uploadSubscriptionLogo(editing.id, datei);
@@ -476,7 +486,7 @@ export default function Abos() {
<label className={"btn sm" + (editing ? "" : " deaktiviert")}>
<Icon name="upload" size={14} /> {t("abos.logoUpload")}
<input type="file" accept="image/*" hidden disabled={!editing}
onChange={(e) => logoWaehlen(e.target.files?.[0])} />
onChange={(e) => { logoWaehlen(e.target.files?.[0]); e.target.value = ""; }} />
</label>
{editing?.has_logo && (
<button type="button" className="btn sm ghost" onClick={logoEntfernen}>
@@ -531,6 +541,14 @@ export default function Abos() {
</form>
</Modal>
)}
{zuschnitt && (
<LogoZuschnitt
datei={zuschnitt}
onAbbrechen={() => setZuschnitt(null)}
onFertig={logoHochladen}
/>
)}
</>
);
}