Files
skrift-app/src/components/PreviewPanel.tsx

235 lines
9.5 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 { 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 };
// Vorschau-Optik „Kugelschreiber" – IDENTISCH zu Directus (neuauftrag): blaue
// Tinte, etwas dicker (1.4) + dezenter Wobble (feTurbulence/Displacement) für die
// typische Kuli-Linie. non-scaling-stroke bleibt (Glyphen skalieren per transform),
// weißer Grund. Nur Anzeige/Download – die an die Produktion gehenden SVGs bleiben
// schwarz. Inline-SVGs brauchen eine EINDEUTIGE Filter-ID pro Dokument.
let inkSeq = 0;
const inkify = (svg: string): string => {
if (typeof svg !== 'string') return svg;
const id = `skink${inkSeq++}`;
return svg
.replace(/stroke="#0{3,6}"/gi, 'stroke="#1a2a6c" stroke-opacity="0.95"')
.replace(/stroke-width="0\.6"/gi, 'stroke-width="1.4"')
.replace(/(<svg[^>]*>)/i, `$1<defs><filter id="${id}" x="-4%" y="-4%" width="108%" height="108%" color-interpolation-filters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="1" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale="1.3" xChannelSelector="R" yChannelSelector="G"/></filter></defs>`)
.replace(/(<rect[^>]*fill="#FFFFFF"[^>]*\/>)/i, `$1<g filter="url(#${id})">`)
.replace(/<\/svg>\s*$/i, '</g></svg>');
};
/** 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);
// Natürliche Größe (nicht 2×): non-scaling-stroke wirkt konstant in
// Gerätepixeln – bei 2× würde die Linie relativ dünn. So sieht das PNG aus
// wie die Großansicht. Gespeicherte Vorschau in Kuli-Optik (wie Anzeige);
// die an die Produktion gehenden SVGs bleiben schwarz.
const scale = 1;
const inkSvg = inkify(zoomSvg);
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: inkify(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: inkify(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>
);
}