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>
190 lines
6.8 KiB
TypeScript
190 lines
6.8 KiB
TypeScript
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;
|
||
});
|
||
}
|