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:
co-authored by
Claude Opus 5
parent
dd2c33725f
commit
9b9ce77267
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user