feat: Der Zuschnitt ist ein Rahmen, den man aufzieht
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9b9ce77267
commit
26718b1301
@@ -1,49 +1,64 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import Icon from "./Icon";
|
|
||||||
|
|
||||||
// Kantenlänge des Rahmens auf dem Schirm. Klein genug fürs Telefon, gross
|
// Wie gross das Bild im Dialog höchstens dargestellt wird. Passt in den
|
||||||
// genug, dass man sieht, was man abschneidet.
|
// schmalen Dialog und auf ein Telefon.
|
||||||
const RAHMEN = 260;
|
const FLAECHE_B = 320;
|
||||||
|
const FLAECHE_H = 280;
|
||||||
|
|
||||||
// Kantenlänge des Ergebnisses. Doppelt so gross wie das, was der Server
|
// Längste Kante des Ergebnisses. Der Server rechnet auf 128 herunter; mit
|
||||||
// speichert (128) – so hat sein Verkleinern noch Reserve und das Logo wird
|
// Reserve bleibt das Logo auf einem dichten Bildschirm scharf.
|
||||||
// auf einem dichten Bildschirm nicht unscharf.
|
const AUSGABE_MAX = 512;
|
||||||
const AUSGABE = 256;
|
|
||||||
|
|
||||||
// Wie weit man hineinzoomen darf, gemessen am eingepassten Bild.
|
// Kleinster Ausschnitt in Bildschirm-Punkten. Kleiner liesse er sich nicht
|
||||||
const MAX_ZOOM = 6;
|
// 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
|
* Der Ausschnitt wird in BILDPUNKTEN geführt, nicht in Bildschirmpunkten.
|
||||||
* niemanden etwas, der gar nicht zuschneiden will – er drückt Übernehmen und
|
* Umgerechnet wird nur für die Anzeige – so ist das Ergebnis genau der
|
||||||
* bekommt, was er hochgeladen hat. Zuschneiden ist ein Zoom, kein Zwang.
|
* 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 }) {
|
export default function LogoZuschnitt({ datei, onAbbrechen, onFertig }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [bild, setBild] = useState(null);
|
const [bild, setBild] = useState(null);
|
||||||
const [fehler, setFehler] = 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 [busy, setBusy] = useState(false);
|
||||||
|
// Ausschnitt in Bildpunkten des Originals.
|
||||||
|
const [rahmen, setRahmen] = useState(null);
|
||||||
const flaeche = useRef(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
|
// 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
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!datei) return undefined;
|
if (!datei) return undefined;
|
||||||
let weg = false;
|
let weg = false;
|
||||||
const url = URL.createObjectURL(datei);
|
const url = URL.createObjectURL(datei);
|
||||||
const img = new Image();
|
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.onerror = () => { if (!weg) setFehler(t("abos.cropUnreadable")); };
|
||||||
img.src = url;
|
img.src = url;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -52,63 +67,93 @@ export default function LogoZuschnitt({ datei, onAbbrechen, onFertig }) {
|
|||||||
};
|
};
|
||||||
}, [datei, t]);
|
}, [datei, t]);
|
||||||
|
|
||||||
// Massstab, bei dem das ganze Bild in den Rahmen passt.
|
// Anzeigegrösse: das ganze Bild, eingepasst.
|
||||||
const passt = bild ? Math.min(RAHMEN / bild.naturalWidth, RAHMEN / bild.naturalHeight) : 1;
|
const skala = bild
|
||||||
const massstab = passt * zoom;
|
? Math.min(FLAECHE_B / bild.naturalWidth, FLAECHE_H / bild.naturalHeight, 1)
|
||||||
const breite = bild ? bild.naturalWidth * massstab : 0;
|
: 1;
|
||||||
const hoehe = bild ? bild.naturalHeight * massstab : 0;
|
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
|
function begrenze(r) {
|
||||||
// ein kleines Bild aus seinem eigenen Ausschnitt heraus.
|
if (!bild) return r;
|
||||||
function begrenze(v) {
|
const maxB = bild.naturalWidth;
|
||||||
const maxX = Math.max(0, (breite - RAHMEN) / 2);
|
const maxH = bild.naturalHeight;
|
||||||
const maxY = Math.max(0, (hoehe - RAHMEN) / 2);
|
const w = Math.min(Math.max(r.w, minBild), maxB);
|
||||||
|
const h = Math.min(Math.max(r.h, minBild), maxH);
|
||||||
return {
|
return {
|
||||||
x: Math.min(maxX, Math.max(-maxX, v.x)),
|
w, h,
|
||||||
y: Math.min(maxY, Math.max(-maxY, v.y)),
|
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 starte(e, griff) {
|
||||||
|
e.preventDefault();
|
||||||
function beiDown(e) {
|
e.stopPropagation();
|
||||||
if (breite <= RAHMEN && hoehe <= RAHMEN) return;
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
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;
|
function bewege(e) {
|
||||||
const d = ziehen.current;
|
const z = zug.current;
|
||||||
setVersatz(begrenze({
|
if (!z || !bild) return;
|
||||||
x: d.start.x + (e.clientX - d.x),
|
// Bildschirm-Weg in Bildpunkte umrechnen – der Rahmen lebt im Original.
|
||||||
y: d.start.y + (e.clientY - d.y),
|
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);
|
function beende(e) {
|
||||||
ziehen.current = null;
|
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() {
|
async function uebernehmen() {
|
||||||
if (!bild) return;
|
if (!bild || !rahmen) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
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");
|
const leinwand = document.createElement("canvas");
|
||||||
leinwand.width = AUSGABE;
|
leinwand.width = Math.max(1, Math.round(rahmen.w * f));
|
||||||
leinwand.height = AUSGABE;
|
leinwand.height = Math.max(1, Math.round(rahmen.h * f));
|
||||||
const g = leinwand.getContext("2d");
|
const g = leinwand.getContext("2d");
|
||||||
// Nichts füllen: Der Grund bleibt durchsichtig. Ein weisser Kasten
|
// Nichts füllen: Der Grund bleibt durchsichtig. Ein weisser Kasten
|
||||||
// fiele auf dunklem Hintergrund sofort auf.
|
// fiele auf dunklem Hintergrund sofort auf.
|
||||||
g.imageSmoothingQuality = "high";
|
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(
|
g.drawImage(
|
||||||
bild,
|
bild,
|
||||||
(AUSGABE - breite * f) / 2 + versatz.x * f,
|
rahmen.x, rahmen.y, rahmen.w, rahmen.h,
|
||||||
(AUSGABE - hoehe * f) / 2 + versatz.y * f,
|
0, 0, leinwand.width, leinwand.height,
|
||||||
breite * f,
|
|
||||||
hoehe * f,
|
|
||||||
);
|
);
|
||||||
const blob = await new Promise((r) => leinwand.toBlob(r, "image/png"));
|
const blob = await new Promise((r) => leinwand.toBlob(r, "image/png"));
|
||||||
if (!blob) throw new Error(t("abos.cropFailed"));
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -144,41 +192,47 @@ export default function LogoZuschnitt({ datei, onAbbrechen, onFertig }) {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={flaeche}
|
ref={flaeche}
|
||||||
className={`zuschnitt-rahmen${beweglich ? " beweglich" : ""}`}
|
className="zuschnitt-flaeche"
|
||||||
style={{ width: RAHMEN, height: RAHMEN }}
|
style={{ width: zeigB || FLAECHE_B, height: zeigH || FLAECHE_H }}
|
||||||
onPointerDown={beiDown}
|
onPointerMove={bewege}
|
||||||
onPointerMove={beiMove}
|
onPointerUp={beende}
|
||||||
onPointerUp={beiUp}
|
onPointerCancel={beende}
|
||||||
onPointerCancel={beiUp}
|
|
||||||
>
|
>
|
||||||
{bild && (
|
{bild && <img src={bild.src} alt="" draggable={false} />}
|
||||||
<img
|
{r && (
|
||||||
src={bild.src}
|
<div
|
||||||
alt=""
|
className="zuschnitt-rahmen"
|
||||||
draggable={false}
|
style={{ left: r.l, top: r.o, width: r.b, height: r.h }}
|
||||||
style={{
|
onPointerDown={(e) => starte(e, "move")}
|
||||||
width: breite,
|
onPointerMove={bewege}
|
||||||
height: hoehe,
|
onPointerUp={beende}
|
||||||
transform: `translate(${versatz.x}px, ${versatz.y}px)`,
|
onPointerCancel={beende}
|
||||||
}}
|
>
|
||||||
/>
|
{GRIFFE.map((g) => (
|
||||||
|
<span
|
||||||
|
key={g.id}
|
||||||
|
className={`zuschnitt-griff griff-${g.id}`}
|
||||||
|
onPointerDown={(e) => starte(e, g.id)}
|
||||||
|
onPointerMove={bewege}
|
||||||
|
onPointerUp={beende}
|
||||||
|
onPointerCancel={beende}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="zuschnitt-regler">
|
<div className="zuschnitt-fuss">
|
||||||
<Icon name="search" size={15} />
|
<span className="muted small">
|
||||||
<input
|
{rahmen ? t("abos.cropSize", {
|
||||||
type="range"
|
breite: Math.round(rahmen.w), hoehe: Math.round(rahmen.h),
|
||||||
min="1"
|
}) : ""}
|
||||||
max={MAX_ZOOM}
|
</span>
|
||||||
step="0.02"
|
<button type="button" className="btn sm ghost" onClick={alles}>
|
||||||
value={zoom}
|
{t("abos.cropAll")}
|
||||||
onChange={(e) => setZoom(Number(e.target.value))}
|
</button>
|
||||||
/>
|
</div>
|
||||||
</label>
|
<p className="muted small">{t("abos.cropHint")}</p>
|
||||||
<p className="muted small">
|
|
||||||
{beweglich ? t("abos.cropHintMove") : t("abos.cropHint")}
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
+70
-20
@@ -1774,38 +1774,88 @@ tr.row-dup {
|
|||||||
|
|
||||||
/* --- Logo zuschneiden ---------------------------------------------------- */
|
/* --- Logo zuschneiden ---------------------------------------------------- */
|
||||||
|
|
||||||
/* Der Rahmen IST der Ausschnitt: Was hier steht, wird gespeichert. Deshalb
|
.zuschnitt-flaeche {
|
||||||
nichts abdunkeln und keine Hilfslinien – man soll das Ergebnis sehen, nicht
|
|
||||||
eine Vorschau davon. */
|
|
||||||
.zuschnitt-rahmen {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
margin: 0 auto var(--sp-2);
|
||||||
margin: 0 auto var(--sp-3);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius);
|
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:
|
background:
|
||||||
repeating-conic-gradient(var(--border) 0% 25%, transparent 0% 50%) 50% / 16px 16px;
|
repeating-conic-gradient(var(--border) 0% 25%, transparent 0% 50%) 50% / 16px 16px;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.zuschnitt-rahmen.beweglich { cursor: grab; }
|
.zuschnitt-flaeche img {
|
||||||
.zuschnitt-rahmen.beweglich:active { cursor: grabbing; }
|
display: block;
|
||||||
.zuschnitt-rahmen img {
|
width: 100%;
|
||||||
position: absolute;
|
height: 100%;
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
margin-left: -50%;
|
|
||||||
margin-top: 0;
|
|
||||||
translate: 0 -50%;
|
|
||||||
max-width: none;
|
|
||||||
pointer-events: none;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
margin-bottom: var(--sp-2);
|
margin-bottom: var(--sp-2);
|
||||||
}
|
}
|
||||||
.zuschnitt-regler input[type="range"] { flex: 1; width: auto; }
|
.zuschnitt-fuss span { flex: 1; font-variant-numeric: tabular-nums; }
|
||||||
.zuschnitt-regler .icon { color: var(--muted); flex: none; }
|
|
||||||
|
/* 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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -374,10 +374,11 @@
|
|||||||
"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",
|
"cropTitle": "Ausschnitt wählen",
|
||||||
"cropApply": "Übernehmen",
|
"cropApply": "Übernehmen",
|
||||||
"cropHint": "Zoom den Regler auf, um einen Ausschnitt zu wählen. So wie es hier steht, wird es gespeichert.",
|
"cropHint": "Rahmen ziehen zum Verschieben, an den Griffen aufziehen. Was im Rahmen steht, wird 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.",
|
"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": {
|
"import": {
|
||||||
"title": "Import",
|
"title": "Import",
|
||||||
|
|||||||
@@ -374,10 +374,11 @@
|
|||||||
"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",
|
"cropTitle": "Choose the crop",
|
||||||
"cropApply": "Use this",
|
"cropApply": "Use this",
|
||||||
"cropHint": "Zoom in to pick a section. 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.",
|
||||||
"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.",
|
"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": {
|
"import": {
|
||||||
"title": "Import",
|
"title": "Import",
|
||||||
|
|||||||
Reference in New Issue
Block a user