Files
Flaro/web/app/src/bildzuschnitt.ts
T
ScarriffleandClaude Opus 5 6dc7be9822 Ein eigenes Bild bringt auch seinen Ausschnitt mit
Hochladen konnte man Plakat, Hintergrund und Schriftzug schon lange — der
Knopf stand da, der Endpunkt nahm an. Nur ankommen tat nichts: die Ablage
baute einen Schlüssel, den sie selbst nicht wiederfand (heute früh
repariert). Was blieb, waren drei Lücken, die man erst sieht, wenn der Weg
überhaupt funktioniert.

**Der Ausschnitt.** Ein Plakat steht hochkant im Verhältnis 2∶3, ein
Hintergrund liegt quer. Was man aussucht, passt selten von selbst — und
den Rest beschnitt bisher der Browser beim Anzeigen: mittig. Jetzt wählt
man selbst, zieht und vergrössert, und hochgeladen wird genau dieser
Ausschnitt: 600×900 beim Plakat, 1920×1080 beim Hintergrund. Gerechnet
wird im Browser; das spart die Leitung und dem Server eine Bildbibliothek.

**Der Schriftzug nicht.** Er ist ein PNG mit durchsichtigem Grund. Durch
dieselbe Mühle geschickt würde aus der Durchsicht ein weisses Rechteck —
ein Aufkleber über dem Bild. Er geht unverändert hinaus, Byte für Byte.

**Die Kachel merkte nichts.** Nach dem Hochladen fehlte der Anstoss nach
aussen; die Oberfläche zeigte weiter das alte Bild, bis man sie neu lud.

**Und zurück ging es gar nicht.** „Von Hand gesetzt" heisst auch: beim
Auffrischen bleibt es stehen. Ohne Rückweg wäre ein Fehlgriff ein
Dauerzustand — jetzt steht „Wieder automatisch" daneben, sobald etwas
festgesetzt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 19:41:04 +02:00

190 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { knopf } from './util';
import { icons } from './icons';
import { beimSeitenwechselSchliessen } from './dialog';
/** Was für ein Ausschnitt gebraucht wird. */
export type Zuschnitt = {
/** Breite geteilt durch Höhe. 1 für ein Quadrat, 2/3 für ein Plakat. */
verhaeltnis: number;
/** Wie breit das Ergebnis wird; die Höhe folgt aus dem Verhältnis. */
breite: number;
/** Rund gezeigt — beim Profilbild, weil es rund dasteht. */
rund?: boolean;
titel?: string;
};
/** Schneidet ein Bild zu, bevor es hochgeladen wird.
*
* **Weil ein Bild eine Form hat.** Ein Profilbild ist rund, ein Plakat steht
* hochkant im Verhältnis 2∶3, ein Hintergrund liegt quer. Was hochgeladen
* wird, passt selten von selbst — und der Browser beschnitt es mit
* `object-fit: cover` mittig, was bei einem Porträt den Hals trifft. Wer ein
* Bild aussucht, soll auch aussuchen, welcher Teil davon zu sehen ist.
*
* **Im Browser gerechnet, nicht auf dem Server.** Hochgeladen wird genau der
* Ausschnitt. Das spart die Leitung, den Platz und dem Server eine
* Bildbibliothek, die er sonst nirgends braucht.
*
* **Nicht für Schriftzüge.** Die liegen als PNG mit durchsichtigem Grund vor;
* sie durch diese Mühle zu schicken hiesse, aus der Durchsicht ein weisses
* Rechteck zu machen. Sie gehen unverändert hinaus.
*/
export function zuschneiden(datei: File, o: Zuschnitt): Promise<Blob | null> {
return new Promise(fertig => {
const bild = new Image();
const adresse = URL.createObjectURL(datei);
bild.onerror = () => {
URL.revokeObjectURL(adresse);
fertig(null);
};
bild.onload = () => {
const d = document.createElement('dialog');
d.className = 'dlg zuschnitt-dlg';
const kopf = document.createElement('div');
kopf.className = 'dlg-kopf';
const h = document.createElement('h2');
h.textContent = o.titel ?? 'Ausschnitt wählen';
const zu = document.createElement('button');
zu.className = 'dlg-zu';
zu.innerHTML = icons.schliessen;
zu.setAttribute('aria-label', 'Schliessen');
zu.onclick = () => d.close();
kopf.append(h, zu);
const buehne = document.createElement('div');
buehne.className = 'zuschnitt-buehne';
const leinwand = document.createElement('canvas');
// **Die Vorschau passt in ein Quadrat von 320**, egal welches Verhältnis
// — ein Plakat wird hoch und schmal, ein Hintergrund breit und flach,
// und beides bleibt im Dialog.
const LANG = 320;
const feldB = o.verhaeltnis >= 1 ? LANG : Math.round(LANG * o.verhaeltnis);
const feldH = o.verhaeltnis >= 1 ? Math.round(LANG / o.verhaeltnis) : LANG;
leinwand.width = feldB;
leinwand.height = feldH;
leinwand.className = 'zuschnitt-feld' + (o.rund ? ' zuschnitt-rund' : '');
leinwand.style.width = `${feldB}px`;
leinwand.style.height = `${feldH}px`;
buehne.appendChild(leinwand);
const regler = document.createElement('input');
regler.type = 'range';
regler.className = 'feld';
regler.min = '100';
regler.max = '300';
regler.value = '100';
regler.setAttribute('aria-label', 'Vergrössern');
const hinweis = document.createElement('p');
hinweis.className = 'hinweis';
hinweis.textContent = 'Ziehen verschiebt den Ausschnitt, der Regler vergrössert.';
const fuss = document.createElement('div');
fuss.className = 'dlg-fuss';
const abbrechen = knopf('Abbrechen', 'btn');
const nehmen = knopf('Übernehmen', 'btn btn-primary');
fuss.append(abbrechen, nehmen);
const koerper = document.createElement('div');
koerper.className = 'dlg-koerper';
koerper.append(buehne, regler, hinweis);
d.append(kopf, koerper, fuss);
document.body.appendChild(d);
d.showModal();
beimSeitenwechselSchliessen(d);
/* ---------- Was gezeigt wird ---------- */
// **Der Nullpunkt ist „ganz drin".** Die kleinere Seite füllt das
// Quadrat; alles darüber ist Vergrösserung. Damit ist der Ausschnitt
// am Anfang immer gültig, egal wie das Bild liegt.
const grund = Math.max(feldB / bild.width, feldH / bild.height);
let faktor = 1;
let x = 0;
let y = 0;
function begrenzen(): void {
const breite = bild.width * grund * faktor;
const hoehe = bild.height * grund * faktor;
// Keine Lücke am Rand: der Ausschnitt bleibt im Bild.
x = Math.min(0, Math.max(feldB - breite, x));
y = Math.min(0, Math.max(feldH - hoehe, y));
}
function zeichnen(): void {
const stift = leinwand.getContext('2d');
if (!stift) return;
begrenzen();
stift.clearRect(0, 0, feldB, feldH);
stift.drawImage(bild, x, y, bild.width * grund * faktor, bild.height * grund * faktor);
}
zeichnen();
regler.oninput = () => {
const neu = Number(regler.value) / 100;
// Um die Mitte vergrössern, nicht um die linke obere Ecke — sonst
// wandert das Motiv beim Ziehen am Regler aus dem Bild.
const mx = feldB / 2;
const my = feldH / 2;
x = mx - (mx - x) * (neu / faktor);
y = my - (my - y) * (neu / faktor);
faktor = neu;
zeichnen();
};
/* ---------- Ziehen ---------- */
let zieht: { x: number; y: number } | null = null;
leinwand.addEventListener('pointerdown', e => {
zieht = { x: e.clientX - x, y: e.clientY - y };
leinwand.setPointerCapture(e.pointerId);
});
leinwand.addEventListener('pointermove', e => {
if (!zieht) return;
x = e.clientX - zieht.x;
y = e.clientY - zieht.y;
zeichnen();
});
const loslassen = (): void => { zieht = null; };
leinwand.addEventListener('pointerup', loslassen);
leinwand.addEventListener('pointercancel', loslassen);
/* ---------- Ergebnis ---------- */
let ergebnis: Blob | null = null;
nehmen.onclick = () => {
const aus = document.createElement('canvas');
aus.width = o.breite;
aus.height = Math.round(o.breite / o.verhaeltnis);
const stift = aus.getContext('2d');
if (!stift) { d.close(); return; }
const massstab = o.breite / feldB;
stift.drawImage(
bild,
x * massstab, y * massstab,
bild.width * grund * faktor * massstab,
bild.height * grund * faktor * massstab);
aus.toBlob(b => { ergebnis = b; d.close(); }, 'image/jpeg', 0.9);
};
abbrechen.onclick = () => d.close();
d.addEventListener('close', () => {
d.remove();
URL.revokeObjectURL(adresse);
fertig(ergebnis);
});
d.addEventListener('click', e => { if (e.target === d) d.close(); });
};
bild.src = adresse;
});
}