diff --git a/web/src/components/LogoZuschnitt.jsx b/web/src/components/LogoZuschnitt.jsx index f68f22e..fc4bb4a 100644 --- a/web/src/components/LogoZuschnitt.jsx +++ b/web/src/components/LogoZuschnitt.jsx @@ -1,49 +1,64 @@ 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; +// 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; -// 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; +// 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; -// Wie weit man hineinzoomen darf, gemessen am eingepassten Bild. -const MAX_ZOOM = 6; +// 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: ziehen zum Verschieben, Regler zum Zoomen. + * Ausschnitt eines Bildes wählen: ein Rahmen, den man verschiebt und an den + * Ecken aufzieht. * - * 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. + * 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 [zoom, setZoom] = useState(1); - const [versatz, setVersatz] = useState({ x: 0, y: 0 }); const [busy, setBusy] = useState(false); + // Ausschnitt in Bildpunkten des Originals. + const [rahmen, setRahmen] = useState(null); const flaeche = useRef(null); - const ziehen = useRef(null); + const zug = 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 + // 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 laengst geladen hatte. + // "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) setBild(img); }; + 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 () => { @@ -52,63 +67,93 @@ export default function LogoZuschnitt({ datei, onAbbrechen, onFertig }) { }; }, [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; + // 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)); - // 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); + 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 { - x: Math.min(maxX, Math.max(-maxX, v.x)), - y: Math.min(maxY, Math.max(-maxY, v.y)), + w, h, + x: Math.min(Math.max(r.x, 0), maxB - w), + y: Math.min(Math.max(r.y, 0), maxH - h), }; } - useEffect(() => { setVersatz((v) => begrenze(v)); /* eslint-disable-next-line */ }, [zoom, bild]); - - function beiDown(e) { - if (breite <= RAHMEN && hoehe <= RAHMEN) return; + function starte(e, griff) { + e.preventDefault(); + e.stopPropagation(); e.currentTarget.setPointerCapture(e.pointerId); - ziehen.current = { x: e.clientX, y: e.clientY, start: versatz }; + zug.current = { griff, x: e.clientX, y: e.clientY, start: rahmen, ziel: e.currentTarget }; } - 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 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 beiUp(e) { - if (ziehen.current) e.currentTarget.releasePointerCapture(e.pointerId); - ziehen.current = null; + + 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) return; + 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 = AUSGABE; - leinwand.height = AUSGABE; + 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"; - // 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, + 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")); @@ -120,7 +165,10 @@ export default function LogoZuschnitt({ datei, onAbbrechen, onFertig }) { } } - const beweglich = breite > RAHMEN || hoehe > RAHMEN; + // 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 (
- {bild && ( - + {bild && } + {r && ( +
starte(e, "move")} + onPointerMove={bewege} + onPointerUp={beende} + onPointerCancel={beende} + > + {GRIFFE.map((g) => ( + starte(e, g.id)} + onPointerMove={bewege} + onPointerUp={beende} + onPointerCancel={beende} + /> + ))} +
)}
- -

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

+
+ + {rahmen ? t("abos.cropSize", { + breite: Math.round(rahmen.w), hoehe: Math.round(rahmen.h), + }) : ""} + + +
+

{t("abos.cropHint")}

)}
diff --git a/web/src/finance.css b/web/src/finance.css index a05d617..0d36377 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1774,38 +1774,88 @@ tr.row-dup { /* --- 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 { +.zuschnitt-flaeche { position: relative; - overflow: hidden; - margin: 0 auto var(--sp-3); - border: 1px solid var(--border-strong); + margin: 0 auto var(--sp-2); border-radius: var(--radius); + /* Schachbrett: zeigt, wo das Bild durchsichtig ist. Bei einem freigestellten + Logo ist das der Unterschied zwischen "weisser Kasten" und "nichts da". */ background: repeating-conic-gradient(var(--border) 0% 25%, transparent 0% 50%) 50% / 16px 16px; touch-action: none; user-select: none; + overflow: hidden; } -.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; +.zuschnitt-flaeche img { + display: block; + width: 100%; + height: 100%; pointer-events: none; } -.zuschnitt-regler { +.zuschnitt-rahmen { + position: absolute; + box-sizing: border-box; + border: 1px solid #fff; + cursor: move; + /* Alles ausserhalb abdunkeln – mit EINEM Schatten statt vier Kacheln. + So bleibt der Ausschnitt das Helle, und man sieht sofort, was bleibt. */ + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55); +} +/* Drittel-Linien: helfen beim Ausrichten, ohne den Ausschnitt zuzustellen. */ +.zuschnitt-rahmen::before, +.zuschnitt-rahmen::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.35; +} +.zuschnitt-rahmen::before { + border-left: 1px solid #fff; + border-right: 1px solid #fff; + left: 33.33%; + right: 33.33%; +} +.zuschnitt-rahmen::after { + border-top: 1px solid #fff; + border-bottom: 1px solid #fff; + top: 33.33%; + bottom: 33.33%; +} + +.zuschnitt-griff { + position: absolute; + width: 14px; + height: 14px; + background: #fff; + border-radius: 3px; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35); +} +.griff-nw { left: -7px; top: -7px; cursor: nwse-resize; } +.griff-n { left: calc(50% - 7px); top: -7px; cursor: ns-resize; } +.griff-ne { right: -7px; top: -7px; cursor: nesw-resize; } +.griff-w { left: -7px; top: calc(50% - 7px); cursor: ew-resize; } +.griff-e { right: -7px; top: calc(50% - 7px); cursor: ew-resize; } +.griff-sw { left: -7px; bottom: -7px; cursor: nesw-resize; } +.griff-s { left: calc(50% - 7px); bottom: -7px; cursor: ns-resize; } +.griff-se { right: -7px; bottom: -7px; cursor: nwse-resize; } + +.zuschnitt-fuss { 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; } +.zuschnitt-fuss span { flex: 1; font-variant-numeric: tabular-nums; } + +/* Auf dem Telefon sind die Griffe mit dem Finger sonst nicht zu treffen. */ +@media (pointer: coarse) { + .zuschnitt-griff { width: 20px; height: 20px; } + .griff-nw, .griff-w, .griff-sw { left: -10px; } + .griff-ne, .griff-e, .griff-se { right: -10px; } + .griff-nw, .griff-n, .griff-ne { top: -10px; } + .griff-sw, .griff-s, .griff-se { bottom: -10px; } + .griff-n, .griff-s { left: calc(50% - 10px); } + .griff-w, .griff-e { top: calc(50% - 10px); } +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 9da0304..81424b1 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -374,10 +374,11 @@ "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.", + "cropHint": "Rahmen ziehen zum Verschieben, an den Griffen aufziehen. Was im Rahmen steht, wird gespeichert.", "cropUnreadable": "Das ist kein lesbares Bild. PNG, JPEG, WEBP oder GIF gehen.", - "cropFailed": "Der Ausschnitt liess sich nicht erzeugen." + "cropFailed": "Der Ausschnitt liess sich nicht erzeugen.", + "cropAll": "Ganzes Bild", + "cropSize": "Ausschnitt {{breite}} × {{hoehe}}" }, "import": { "title": "Import", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index e717d4e..d01a3a2 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -374,10 +374,11 @@ "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.", + "cropHint": "Drag the frame to move it, drag the handles to resize. What is inside the frame gets saved.", "cropUnreadable": "That is not a readable image. PNG, JPEG, WEBP or GIF work.", - "cropFailed": "The crop could not be created." + "cropFailed": "The crop could not be created.", + "cropAll": "Whole image", + "cropSize": "Crop {{breite}} × {{hoehe}}" }, "import": { "title": "Import",