216 lines
8.3 KiB
TypeScript
216 lines
8.3 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
|
||
/**
|
||
* Live-Vorschau neben dem Formular.
|
||
*
|
||
* Die Vorschau ist absichtlich kontingentiert: Jede Generierung kostet einen
|
||
* Aufruf der Scriptalizer-API (Tageslimit). Der Zähler ist daher Teil der
|
||
* Bedienung und kein Beiwerk.
|
||
*
|
||
* Schriftstück und zugehöriger Umschlag gehören zusammen und werden gemeinsam
|
||
* gezeigt (Schriftstück oben, Umschlag unten). Die Pfeile blättern je Empfänger.
|
||
*/
|
||
|
||
export type PreviewDoc = { art: 'brief' | 'umschlag'; svg: string };
|
||
|
||
/** Ein Empfänger-Dokument: Schriftstück + optional zugehöriger Umschlag. */
|
||
type Gruppe = { brief?: PreviewDoc; umschlag?: PreviewDoc };
|
||
|
||
type Props = {
|
||
titel: string;
|
||
format: string;
|
||
docs: PreviewDoc[];
|
||
gesamtDokumente: number;
|
||
verbleibend: number;
|
||
maximal: number;
|
||
laedt: boolean;
|
||
fehler: string | null;
|
||
onGenerate: () => void;
|
||
bereit: boolean;
|
||
};
|
||
|
||
/** Flache [brief, umschlag, brief, …]-Liste zu Empfänger-Gruppen bündeln. */
|
||
function gruppiere(docs: PreviewDoc[]): Gruppe[] {
|
||
const gruppen: Gruppe[] = [];
|
||
for (const d of docs) {
|
||
if (d.art === 'umschlag') {
|
||
const letzte = gruppen[gruppen.length - 1];
|
||
if (letzte && letzte.brief && !letzte.umschlag) letzte.umschlag = d;
|
||
else gruppen.push({ umschlag: d });
|
||
} else {
|
||
gruppen.push({ brief: d });
|
||
}
|
||
}
|
||
return gruppen;
|
||
}
|
||
|
||
export default function PreviewPanel({
|
||
titel, format, docs, gesamtDokumente, verbleibend, maximal, laedt, fehler, onGenerate, bereit,
|
||
}: Props) {
|
||
const [index, setIndex] = useState(0);
|
||
const [zoomSvg, setZoomSvg] = useState<string | null>(null);
|
||
// Die Zoom-Fläche wird auf den Inhaltsbereich begrenzt (unterhalb der
|
||
// Preiszeile, rechts der Seitenleiste), statt das ganze Fenster zu belegen.
|
||
const [zoomBox, setZoomBox] = useState<React.CSSProperties>({});
|
||
|
||
const gruppen = useMemo(() => gruppiere(docs), [docs]);
|
||
|
||
function messeBox(): React.CSSProperties {
|
||
if (typeof window === 'undefined' || window.innerWidth <= 900) return {};
|
||
const content = document.querySelector('.sk-content');
|
||
if (!content) return {};
|
||
const c = content.getBoundingClientRect();
|
||
const bar = document.querySelector('.sk-pricebar');
|
||
const top = Math.max(c.top, bar ? bar.getBoundingClientRect().bottom : c.top);
|
||
return {
|
||
top: `${top}px`, left: `${c.left}px`, width: `${c.width}px`,
|
||
height: `${Math.max(0, c.bottom - top)}px`, right: 'auto', bottom: 'auto',
|
||
};
|
||
}
|
||
function openZoom(svg: string) { setZoomBox(messeBox()); setZoomSvg(svg); }
|
||
|
||
/** Seitenverhältnis (Breite/Höhe) aus der viewBox – sperrt die Lightbox-Abmessungen. */
|
||
function seitenverhaeltnis(svg: string): string {
|
||
const m = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
|
||
return m ? `${m[1]} / ${m[2]}` : '210 / 297';
|
||
}
|
||
|
||
/** Musterbild der großen Vorschau als PNG herunterladen (SVG → Canvas). */
|
||
function bildSpeichern() {
|
||
if (!zoomSvg) return;
|
||
const m = zoomSvg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
|
||
const w = Math.round(m ? Number(m[1]) : 794);
|
||
const h = Math.round(m ? Number(m[2]) : 1123);
|
||
const scale = 2; // für ein scharfes Musterbild
|
||
// Gespeicherte Vorschau in Kugelschreiber-Blau (wie die Anzeige); die
|
||
// an die Produktion gehenden SVGs bleiben davon unberührt (schwarz).
|
||
const inkSvg = zoomSvg.replace(/stroke="#0{3,6}"/gi, 'stroke="#1a2a6c"');
|
||
const url = URL.createObjectURL(new Blob([inkSvg], { type: 'image/svg+xml;charset=utf-8' }));
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w * scale;
|
||
canvas.height = h * scale;
|
||
const ctx = canvas.getContext('2d');
|
||
if (ctx) {
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
canvas.toBlob((png) => {
|
||
if (png) {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(png);
|
||
a.download = 'skrift-musterbild.png';
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
}
|
||
URL.revokeObjectURL(url);
|
||
}, 'image/png');
|
||
} else {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
};
|
||
img.onerror = () => URL.revokeObjectURL(url);
|
||
img.src = url;
|
||
}
|
||
|
||
// Nach einer neuen Generierung wieder vorn beginnen.
|
||
useEffect(() => { setIndex(0); }, [docs]);
|
||
|
||
// Vergrößerte Ansicht: Escape schließt, Resize misst die Fläche neu.
|
||
useEffect(() => {
|
||
if (!zoomSvg) return;
|
||
const zu = (e: KeyboardEvent) => e.key === 'Escape' && setZoomSvg(null);
|
||
const neu = () => setZoomBox(messeBox());
|
||
window.addEventListener('keydown', zu);
|
||
window.addEventListener('resize', neu);
|
||
return () => { window.removeEventListener('keydown', zu); window.removeEventListener('resize', neu); };
|
||
}, [zoomSvg]);
|
||
|
||
const aktuell = gruppen[index] ?? null;
|
||
const anzahl = gruppen.length || gesamtDokumente;
|
||
|
||
/** Ein einzelnes Dokument (Schriftstück oder Umschlag) mit Beschriftung. */
|
||
const DocBox = ({ label, svg }: { label: string; svg: string }) => (
|
||
<figure
|
||
className="sk-preview-doc"
|
||
onClick={() => openZoom(svg)}
|
||
role="button"
|
||
tabIndex={0}
|
||
title="Zum Vergrößern klicken"
|
||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openZoom(svg); }}
|
||
>
|
||
<figcaption className="sk-preview-doc-label">{label}</figcaption>
|
||
{/* Das SVG stammt aus unserer eigenen Generierung, nicht aus Nutzereingaben. */}
|
||
<div className="sk-preview-doc-sheet" dangerouslySetInnerHTML={{ __html: svg }} />
|
||
</figure>
|
||
);
|
||
|
||
return (
|
||
<aside className="sk-preview">
|
||
<div className="sk-preview-head">
|
||
<div>
|
||
<div className="sk-preview-title">Vorschau: {titel || '—'}</div>
|
||
<div className="sk-preview-sub">{format || '—'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{laedt ? (
|
||
<div className="sk-preview-sheet"><p className="sk-note">Vorschau wird erzeugt …</p></div>
|
||
) : aktuell ? (
|
||
<div className="sk-preview-docs">
|
||
{aktuell.brief && <DocBox label={`Schriftstück${format ? ` · ${format}` : ''}`} svg={aktuell.brief.svg} />}
|
||
{aktuell.umschlag && <DocBox label="Umschlag" svg={aktuell.umschlag.svg} />}
|
||
</div>
|
||
) : (
|
||
<div className="sk-preview-sheet">
|
||
<p className="sk-note" style={{ textAlign: 'center' }}>Noch keine Vorschau erzeugt.</p>
|
||
</div>
|
||
)}
|
||
|
||
{anzahl > 0 && (
|
||
<div className="sk-preview-pager">
|
||
<button className="sk-pager-btn" aria-label="Vorheriges Dokument"
|
||
disabled={index === 0 || gruppen.length <= 1}
|
||
onClick={() => setIndex((i) => Math.max(0, i - 1))}>‹</button>
|
||
<span className="sk-pager-label">
|
||
Dokument {gruppen.length ? index + 1 : 0} von {anzahl}
|
||
</span>
|
||
<button className="sk-pager-btn" aria-label="Nächstes Dokument"
|
||
disabled={index >= gruppen.length - 1 || gruppen.length <= 1}
|
||
onClick={() => setIndex((i) => Math.min(gruppen.length - 1, i + 1))}>›</button>
|
||
</div>
|
||
)}
|
||
|
||
{fehler && <p className="sk-error">{fehler}</p>}
|
||
|
||
<div className="sk-preview-quota">
|
||
Verbleibende Vorschau-Anfragen: {verbleibend}/{maximal}
|
||
</div>
|
||
|
||
<button className="sk-btn sk-btn--ghost sk-btn--block" onClick={onGenerate}
|
||
disabled={!bereit || laedt || verbleibend <= 0}
|
||
title={!bereit ? 'Bitte zuerst Text angeben' : undefined}>
|
||
{laedt ? 'Wird erzeugt …' : 'Vorschau generieren'}
|
||
</button>
|
||
|
||
{zoomSvg && (
|
||
<div className="sk-lightbox" style={zoomBox} onClick={() => setZoomSvg(null)} role="dialog" aria-modal="true"
|
||
aria-label="Vergrößerte Vorschau">
|
||
<div className="sk-lightbox-sheet" style={{ aspectRatio: seitenverhaeltnis(zoomSvg) }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
dangerouslySetInnerHTML={{ __html: zoomSvg }} />
|
||
<div className="sk-lightbox-actions">
|
||
<button className="sk-lightbox-close" onClick={(e) => { e.stopPropagation(); bildSpeichern(); }}>
|
||
Bild speichern
|
||
</button>
|
||
<button className="sk-lightbox-close" onClick={() => setZoomSvg(null)} aria-label="Schließen">
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
);
|
||
}
|