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 { 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; }); }