Import aus Monorepo (frischer Start)
All checks were successful
Build & Deploy / deploy (push) Successful in 26s
All checks were successful
Build & Deploy / deploy (push) Successful in 26s
This commit is contained in:
215
src/components/PreviewPanel.tsx
Normal file
215
src/components/PreviewPanel.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user