From 26718b1301415f348bcd69151d610d7dcffb74d7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 7 Sep 2026 19:59:11 +0200 Subject: [PATCH] feat: Der Zuschnitt ist ein Rahmen, den man aufzieht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zoomen war das falsche Werkzeug. Man konnte nur naeher heran, aber nicht sagen, WAS man behalten will – und das Ergebnis war immer quadratisch, auch wenn das Logo breit ist. Jetzt liegt ein Rahmen auf dem Bild: ziehen zum Verschieben, acht Griffe zum Aufziehen. Aussen abgedunkelt, Drittel-Linien zum Ausrichten, und darunter steht, wie gross der Ausschnitt gerade ist. "Ganzes Bild" setzt zurueck. Das Seitenverhaeltnis des Ausschnitts bleibt. Ein breites Logo wird ein breites Logo, kein quadratisches mit Luft an den Seiten. Der Ausschnitt wird in BILDPUNKTEN gefuehrt, nicht in Bildschirmpunkten – umgerechnet wird nur fuer die Anzeige. So trifft das Ergebnis genau den Bereich im Rahmen, ohne dass sich ueber viele Zieh-Bewegungen Rundungsfehler aufaddieren. Am Bildrand haelt die gegenueberliegende Kante still, statt dass der Rahmen beim Anschlag mitwandert; zieht man einen Griff ueber die eigene Kante hinaus, bleibt sie stehen, statt sich umzustuelpen. Auf dem Telefon sind die Griffe groesser – mit dem Finger waeren vierzehn Pixel nicht zu treffen. Co-Authored-By: Claude Opus 5 --- web/src/components/LogoZuschnitt.jsx | 236 ++++++++++++++++----------- web/src/finance.css | 90 +++++++--- web/src/locales/de.json | 7 +- web/src/locales/en.json | 7 +- 4 files changed, 223 insertions(+), 117 deletions(-) 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",