@@ -1,7 +1,6 @@
import { useEffect , useMemo , useState } from 'react' ;
import { fetchFormats , fetchSettings , generatePreview } from '../lib/api' ;
import type { Format , PublicSettings , PreviewDoc , PreviewOverflow } from '../lib/api' ;
import PreviewPanel from '../components/PreviewPanel' ;
/** Schriftarten wie im Konfigurator/manuellen Druck. */
const SCHRIFTEN = [
@@ -10,6 +9,54 @@ const SCHRIFTEN = [
{ key : 'ellie' , name : 'Ellie' } ,
] ;
/** Kugelschreiber-Optik (identisch zum PreviewPanel): blaue Tinte, dezenter Wobble. */
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>' ) ;
} ;
/** Musterbild als PNG herunterladen (SVG → Canvas), wie im PreviewPanel. */
function bildSpeichern ( svg : string ) {
const m = svg . 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 inkSvg = inkify ( svg ) ;
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 ;
canvas . height = h ;
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-textmenge.png' ;
a . click ( ) ;
URL . revokeObjectURL ( a . href ) ;
}
URL . revokeObjectURL ( url ) ;
} , 'image/png' ) ;
} else {
URL . revokeObjectURL ( url ) ;
}
} ;
img . onerror = ( ) = > URL . revokeObjectURL ( url ) ;
img . src = url ;
}
/** Stabile anonyme Kennung fürs Vorschau-Kontingent (wie im Konfigurator). */
function clientId ( ) : string {
const KEY = 'skrift.client' ;
@@ -32,24 +79,24 @@ function overflowMeldung(overflow?: PreviewOverflow[]) {
}
/**
* Textmengen-Tool (/textmenge): Format- und Schriftwahl, Texteingabe mit denselben
* Möglichkeiten wie sonst (Tabulator, gesperrte Zeichen, [[seitenumbruch]]). Über
* „Vorschau generieren" wird das Schriftstück gerendert (Kugelschreiber-Optik) –
* so sieht man, ob der Text passt, sonst kommt eine Fehlermeldung .
* Textmengen-Tool (/textmenge): Format- und Schriftwahl, Texteingabe (Tabulator,
* gesperrte Zeichen) und „Vorschau generieren" → rendert EIN Schriftstück
* (Kugelschreiber-Optik). Passt der Text nicht auf eine Seite, kommt die Fehlermeldung.
* Bewusst einseitig: kein Seitenumbruch .
*/
export default function Textmenge() {
const [ formate , setFormate ] = useState < Format [ ] > ( [ ] ) ;
const [ settings , setSettings ] = useState < PublicSettings | null > ( null ) ;
const [ format , setFormat ] = useState ( 'a4' ) ;
const [ font , setFont ] = useState ( 'tilda' ) ;
const [ realistic , setRealistic ] = useState ( true ) ;
const [ text , setText ] = useState ( '' ) ;
const [ hinweis , setHinweis ] = useState ( '' ) ;
const [ docs , setDocs ] = useState < PreviewDoc [ ] > ( [ ] ) ;
const [ doc , setDoc ] = useState < PreviewDoc | null > ( null ) ;
const [ laedt , setLaedt ] = useState ( false ) ;
const [ fehler , setFehler ] = useState < string | null > ( null ) ;
const [ verbleibend , setVerbleibend ] = useState ( 0 ) ;
const [ maximal , setMaximal ] = useState ( 0 ) ;
const [ gross , setGross ] = useState ( false ) ;
useEffect ( ( ) = > {
fetchFormats ( )
@@ -61,6 +108,14 @@ export default function Textmenge() {
fetchSettings ( ) . then ( setSettings ) . catch ( ( ) = > { } ) ;
} , [ ] ) ;
// Escape schließt die Großansicht.
useEffect ( ( ) = > {
if ( ! gross ) return ;
const onKey = ( e : KeyboardEvent ) = > { if ( e . key === 'Escape' ) setGross ( false ) ; } ;
window . addEventListener ( 'keydown' , onKey ) ;
return ( ) = > window . removeEventListener ( 'keydown' , onKey ) ;
} , [ gross ] ) ;
// Gesperrte Zeichen wie im Konfigurator (Zeilenumbruch/Tab bleiben erlaubt).
const verbotenRe = useMemo ( ( ) = > {
const wl = settings ? . char_whitelist ;
@@ -82,14 +137,15 @@ export default function Textmenge() {
return v . replace ( verbotenRe , '' ) ;
} ;
// Neuer Text/Format/Schrift → alte Vorschau verwerfen (nicht live neu laden) .
// Änderung an Eingaben → alte Vorschau verwerfen.
useEffect ( ( ) = > {
setDocs ( [ ] ) ;
setDoc ( null ) ;
setFehler ( null ) ;
} , [ text , font , format ] ) ;
setGross ( false ) ;
} , [ text , font , format , realistic ] ) ;
async function generieren() {
if ( ! text . trim ( ) ) return ;
if ( ! text . trim ( ) || laedt ) return ;
setLaedt ( true ) ;
setFehler ( null ) ;
try {
@@ -98,18 +154,17 @@ export default function Textmenge() {
text ,
font ,
format ,
realistic ,
entries : [ { } ] ,
} ) ;
setVerbleibend ( ergebnis . remaining ? ? 0 ) ;
setMaximal ( ergebnis . limit ? ? 0 ) ;
if ( ergebnis . has_overflow ) {
setDocs ( [ ] ) ;
setDoc ( null ) ;
setFehler ( overflowMeldung ( ergebnis . overflow ) ) ;
} else {
setDocs ( ergebnis . docs ? ? [ ] ) ;
setDoc( ( ergebnis . docs ? ? [ ] ) . find ( ( d ) = > d . art === 'brief' ) ? ? ergebnis . docs ? . [ 0 ] ? ? null ) ;
}
} catch ( e ) {
setDocs ( [ ] ) ;
setDoc ( null ) ;
setFehler ( ( e as Error ) ? . message || 'Vorschau fehlgeschlagen.' ) ;
} finally {
setLaedt ( false ) ;
@@ -117,15 +172,17 @@ export default function Textmenge() {
}
return (
< div className = "sk-page" style = { { maxWidth : 980 , margin : '0 auto' } } >
< h1 style = { { margin : '0 0 6px' } } > Textmenge prüfen < / h1 >
< p style = { { color : 'var(--sk-muted, #666)' , margin : '0 0 20px' } } >
Text eingeben , Format und Schrift wählen und eine Vorschau erzeugen – so siehst du , ob der Text auf ein Schriftstück passt .
< / p >
< div className = "sk-konf" >
< div >
< h1 style = { { margin : '0 0 8px' , fontSize : 28 } } > Textmenge prüfen < / h1 >
< p style = { { color : 'var(--sk-muted, #666)' , margin : 0 , lineHeight : 1.6 } } >
Text eingeben , Format und Schrift wählen , dann eine Vorschau erzeugen . So siehst du , ob der Text auf ein Schriftstück passt .
< / p >
< / div >
< div className = "sk-card" >
< div className = "sk-card-body" style = { { display : 'flex' , flexDirection : 'column' , gap : 16 } } >
< div style = { { display : 'flex' , gap : 12 , flexWrap : 'wrap' } } >
< div className = "sk-card-body" style = { { display : 'flex' , flexDirection : 'column' , gap : 20 } } >
< div style = { { display : 'flex' , gap : 16 , flexWrap : 'wrap' } } >
< label className = "sk-field" style = { { flex : '1 1 200px' } } >
< span className = "sk-label" > Format < / span >
< select className = "sk-input" value = { format } onChange = { ( e ) = > setFormat ( e . target . value ) } >
@@ -145,6 +202,11 @@ export default function Textmenge() {
< / label >
< / div >
< label style = { { display : 'flex' , alignItems : 'center' , gap : 10 , cursor : 'pointer' , fontSize : 15 } } >
< input type = "checkbox" checked = { realistic } onChange = { ( e ) = > setRealistic ( e . target . checked ) } style = { { width : 18 , height : 18 } } / >
Realistische Handschrift ( natürliche Variation )
< / label >
< label className = "sk-field" >
< span className = "sk-label" > Text · { text . length } Zeichen < / span >
< textarea
@@ -152,10 +214,9 @@ export default function Textmenge() {
rows = { 12 }
value = { text }
placeholder = "Text eingeben oder einfügen …"
style = { { fontFamily : 'ui-monospace, SFMono-Regular, Menlo, monospace' , resize : 'vertical' } }
style = { { fontFamily : 'ui-monospace, SFMono-Regular, Menlo, monospace' , resize : 'vertical' , lineHeight : 1.6 } }
onChange = { ( e ) = > setText ( saeubere ( e . target . value ) ) }
onKeyDown = { ( e ) = > {
// Tab fügt einen echten Tabulator ein (z. B. Datum an den rechten Rand).
if ( e . key !== 'Tab' || e . shiftKey || e . ctrlKey || e . altKey || e . metaKey ) return ;
e . preventDefault ( ) ;
const ta = e . currentTarget ;
@@ -168,27 +229,90 @@ export default function Textmenge() {
} }
/ >
< / label >
< button
type = "button"
className = "sk-btn sk-btn--primary"
disabled = { ! text . trim ( ) || laedt }
onClick = { generieren }
style = { { alignSelf : 'flex-start' , opacity : ! text . trim ( ) || laedt ? 0.6 : 1 } }
>
{ laedt ? 'Vorschau wird erzeugt …' : 'Vorschau generieren' }
< / button >
< p style = { { color : 'var(--sk-muted, #666)' , fontSize : 12 , margin : 0 , lineHeight : 1.5 } } >
Tabulator ( ↹ ) rückt Text ein · < code > [ [ seitenumbruch ] ] < / code > erzwingt eine neue Seite · g esperrte Zeichen werden entfernt .
Tabulator ( ↹ ) rückt Text ein . G esperrte Zeichen werden automatisch entfernt .
< / p >
{ hinweis && < p style = { { color : 'var(--sk-danger, #c62828)' , fontSize : 13 , margin : 0 } } > { hinweis } < / p > }
< / div >
< / div >
< div style = { { marginTop : 16 } } >
< PreviewPanel
titel = "Vorschau"
format = { format }
docs = { docs }
gesamtDokumente = { docs . length }
verbleibend = { verbleibend }
maximal = { maximal }
laedt = { laedt }
fehler = { fehler }
onGenerate = { generieren }
bereit = { ! ! text . trim ( ) }
/ >
{ /* Vorschau / Ergebnis */ }
< div className = "sk-card" >
< div className = "sk-card-body" >
{ fehler ? (
< div
style = { {
display : 'flex' , gap : 10 , alignItems : 'flex-start' , color : '#c62828' ,
background : 'rgba(198,40,40,.08)' , border : '1px solid rgba(198,40,40,.25)' ,
borderRadius : 10 , padding : '14px 16px' , fontSize : 15 ,
} }
>
< span style = { { fontSize : 18 , lineHeight : 1 } } > ⚠ < / span >
< span > { fehler } < / span >
< / div >
) : doc ? (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 14 , alignItems : 'center' } } >
< div style = { { display : 'flex' , gap : 10 , alignItems : 'center' , flexWrap : 'wrap' , width : '100%' } } >
< span
style = { {
fontSize : 13 , fontWeight : 600 , padding : '4px 12px' , borderRadius : 999 ,
background : 'rgba(46,125,50,.12)' , color : '#2e7d32' ,
} }
>
✓ Passt auf eine Seite
< / span >
< span style = { { flex : 1 } } / >
< button type = "button" className = "sk-btn" onClick = { ( ) = > setGross ( true ) } > 🔍 Vergrößern < / button >
< button type = "button" className = "sk-btn sk-btn--primary" onClick = { ( ) = > bildSpeichern ( doc . svg ) } > ⬇ Bild herunterladen < / button >
< / div >
< div
className = "sk-preview-doc-sheet"
onClick = { ( ) = > setGross ( true ) }
style = { { width : '100%' , maxWidth : 460 , cursor : 'zoom-in' } }
dangerouslySetInnerHTML = { { __html : inkify ( doc . svg ) } }
/ >
< / div >
) : (
< p style = { { color : 'var(--sk-muted, #666)' , margin : 0 , textAlign : 'center' , padding : '40px 0' } } >
Noch keine Vorschau . Text eingeben und „ Vorschau generieren " drücken.
</p>
)}
</div>
</div>
{/* Großansicht (Lightbox) */}
{gross && doc && (
<div
onClick={() => setGross(false)}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,.72)', zIndex: 1000,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 16, padding: 24,
}}
>
<div style={{ display: 'flex', gap: 10 }} onClick={(e) => e.stopPropagation()}>
<button type=" button " className=" sk - btn sk - btn -- primary " onClick={() => bildSpeichern(doc.svg)}>⬇ Bild herunterladen</button>
<button type=" button " className=" sk - btn " onClick={() => setGross(false)}>Schließen</button>
</div>
<div
className=" sk - preview - doc - sheet "
onClick = { ( e ) = > e . stopPropagation ( ) }
style = { { width : 'min(92vw, 62vh)' , maxHeight : '86vh' , overflow : 'auto' } }
dangerouslySetInnerHTML = { { __html : inkify ( doc . svg ) } }
/ >
< / div >
) }
< / div >
) ;
}