DevTools: QR-Code fix (toCanvas statt create), Generieren-Button, 2-Spalten-Nav, CAD-Skizzen Schild zurueck

This commit is contained in:
2026-06-05 01:02:49 +02:00
parent b79e32fc20
commit 66a4b6b823

View File

@@ -2699,97 +2699,53 @@ const CAP_POS = [
{ id:'none', label:'Keine' },
];
function drawQrToCanvas(canvas, data, opts) {
const { size, fgColor, bgColor, margin, dotStyle, cornerStyle, caption, captionPos, captionColor, captionSize } = opts;
function drawQrToCanvas(targetCanvas, data, opts) {
const { size, fgColor, bgColor, margin, caption, captionPos, captionColor, captionSize } = opts;
const capH = caption && captionPos !== 'none' ? (Number(captionSize) + 16) : 0;
const totalH = size + capH;
canvas.width = size;
canvas.height = totalH;
const ctx = canvas.getContext('2d');
const totalH = Number(size) + capH;
// Step 1: generate QR to temporary canvas (black on white, no margin)
// Step 1: generate QR to a temp canvas via qrcode library (browser-compatible)
const tmp = document.createElement('canvas');
return QRCode.toCanvas(tmp, data, {
width: size,
margin: 0,
color: { dark: '#000000ff', light: '#ffffffff' },
width: Number(size),
margin: Number(margin) || 1,
color: { dark: fgColor, light: bgColor },
errorCorrectionLevel: 'M',
}).then(() => {
// Step 2: read pixel data to detect module grid
const tctx = tmp.getContext('2d');
const pixels = tctx.getImageData(0, 0, size, size).data;
// Step 2: compose onto target canvas (with optional caption)
targetCanvas.width = Number(size);
targetCanvas.height = totalH;
const ctx = targetCanvas.getContext('2d');
// Find module size by scanning first dark run
let firstDark = -1, runEnd = -1;
for (let x = 0; x < size; x++) {
const idx = x * 4;
const dark = pixels[idx] < 128;
if (dark && firstDark < 0) firstDark = x;
if (!dark && firstDark >= 0 && runEnd < 0) { runEnd = x; break; }
}
const moduleSize = (runEnd > 0 && firstDark >= 0) ? (runEnd - firstDark) : Math.round(size / 25);
const n = Math.round(size / moduleSize);
// Step 3: draw background
// Background
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, size, totalH);
ctx.fillRect(0, 0, Number(size), totalH);
const qrTop = captionPos === 'top' ? capH : 0;
const pad = Math.round(margin * moduleSize);
const cellSize = (size - pad * 2) / n;
// QR image
const qrTop = captionPos === 'top' ? capH : 0;
ctx.drawImage(tmp, 0, qrTop, Number(size), Number(size));
ctx.fillStyle = fgColor;
for (let row = 0; row < n; row++) {
for (let col = 0; col < n; col++) {
// Sample the center pixel of this module from the temp canvas
const px = Math.round((col + 0.5) * moduleSize);
const py = Math.round((row + 0.5) * moduleSize);
const idx = (py * size + px) * 4;
if (pixels[idx] > 128) continue; // light module
const x = pad + col * cellSize;
const y = qrTop + pad + row * cellSize;
const w = cellSize;
// Finder pattern corners: rows/cols 0-6 and last 6
const isCorner = (row < 7 && col < 7) || (row < 7 && col >= n - 7) || (row >= n - 7 && col < 7);
const style = isCorner ? cornerStyle : dotStyle;
if (style === 'dots') {
const r = w * 0.45;
ctx.beginPath(); ctx.arc(x + w/2, y + w/2, r, 0, Math.PI * 2); ctx.fill();
} else if (style === 'rounded' || style === 'classy') {
const r = style === 'classy' ? w * 0.42 : w * 0.32;
ctx.beginPath(); ctx.roundRect(x + 0.5, y + 0.5, w - 1, w - 1, r); ctx.fill();
} else if (style === 'dot') {
const r = w * 0.45;
ctx.beginPath(); ctx.arc(x + w/2, y + w/2, r, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillRect(x, y, w, w);
}
}
}
// Step 4: caption
// Caption
if (caption && captionPos !== 'none') {
ctx.fillStyle = captionColor;
ctx.font = `${captionSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillStyle = captionColor;
ctx.font = `${captionSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const cy = captionPos === 'top' ? capH / 2 : size + capH / 2;
ctx.fillText(caption, size / 2, cy, size - 16);
const cy = captionPos === 'top' ? capH / 2 : Number(size) + capH / 2;
ctx.fillText(caption, Number(size) / 2, cy, Number(size) - 16);
}
}).catch(err => {
// Show error message on canvas
}).catch(() => {
// Show error on canvas
targetCanvas.width = Number(size);
targetCanvas.height = Number(size);
const ctx = targetCanvas.getContext('2d');
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, size, totalH);
ctx.fillRect(0, 0, Number(size), Number(size));
ctx.fillStyle = '#ff6b9d';
ctx.font = '13px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Ungültige URL', size/2, size/2);
ctx.fillText('Ungültige URL', Number(size)/2, Number(size)/2);
});
}
@@ -2797,9 +2753,9 @@ function QrGenerator({ toast, mobile }) {
const canvasRef = useRef(null);
const [tab, setTab] = useState('create');
const [saved, setSaved] = useState([]);
const [editing, setEditing] = useState(null); // saved qr being edited
const [editing, setEditing] = useState(null);
const [generated, setGenerated] = useState(false); // whether QR is shown
// Form state
const DEFAULTS = {
label:'', url:'', size:256, fgColor:'#000000', bgColor:'#ffffff',
margin:4, dotStyle:'square', cornerStyle:'square',
@@ -2808,24 +2764,25 @@ function QrGenerator({ toast, mobile }) {
const [form, setForm] = useState(DEFAULTS);
const [busy, setBusy] = useState(false);
const set = (k, v) => setForm(p => ({...p, [k]: v}));
const set = (k, v) => { setForm(p => ({...p, [k]: v})); setGenerated(false); };
const loadSaved = () => api('/tools/qrcodes').then(setSaved).catch(()=>{});
useEffect(()=>{ loadSaved(); },[]);
// Re-render QR whenever form changes
useEffect(()=>{
if (!canvasRef.current || !form.url.trim()) return;
drawQrToCanvas(canvasRef.current, form.url, {
const generate = async () => {
if (!form.url.trim()) { toast('URL erforderlich','error'); return; }
if (!canvasRef.current) return;
await drawQrToCanvas(canvasRef.current, form.url, {
size:form.size, fgColor:form.fgColor, bgColor:form.bgColor,
margin:form.margin, dotStyle:form.dotStyle, cornerStyle:form.cornerStyle,
caption:form.caption, captionPos:form.captionPos,
captionColor:form.captionColor, captionSize:form.captionSize,
});
}, [form]);
setGenerated(true);
};
const download = () => {
if (!canvasRef.current) return;
if (!canvasRef.current || !generated) return;
const a = document.createElement('a');
a.href = canvasRef.current.toDataURL('image/png');
a.download = `qrcode-${form.label||'export'}.png`;
@@ -2987,31 +2944,46 @@ function QrGenerator({ toast, mobile }) {
)}
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={download} disabled={!form.url.trim()}
style={{...S.btn('#ffe66d',true),flex:1,textAlign:'center',padding:'8px 0',opacity:form.url.trim()?1:0.4}}>
PNG laden
<button onClick={generate}
style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',fontWeight:700}}>
Generieren
</button>
<button onClick={save} disabled={busy||!form.url.trim()}
style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'8px 0',opacity:form.url.trim()?1:0.4}}>
{busy?'…':editing?'✓ Aktualisieren':'💾 Speichern'}
</button>
{editing && <button onClick={()=>{setEditing(null);setForm(DEFAULTS);}}
{editing && <button onClick={()=>{setEditing(null);setForm(DEFAULTS);setGenerated(false);}}
style={{...S.btn('#ff6b9d',true),padding:'0 12px'}}></button>}
</div>
</div>
{/* Rechte Spalte: Vorschau */}
<div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:12}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>VORSCHAU</div>
{form.url.trim()
? <canvas ref={canvasRef} style={{borderRadius:8,maxWidth:'100%',
boxShadow:'0 4px 24px rgba(0,0,0,0.4)',border:'1px solid rgba(255,255,255,0.08)'}}/>
: <div style={{width:200,height:200,borderRadius:8,background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.08)',display:'flex',alignItems:'center',justifyContent:'center'}}>
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:11}}>URL eingeben </span>
</div>
}
<canvas ref={canvasRef} style={{
borderRadius:8, maxWidth:'100%',
boxShadow:'0 4px 24px rgba(0,0,0,0.4)',
border:'1px solid rgba(255,255,255,0.08)',
display: generated ? 'block' : 'none'}}/>
{!generated && (
<div style={{width:200,height:200,borderRadius:8,background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.08)',display:'flex',flexDirection:'column',
alignItems:'center',justifyContent:'center',gap:10}}>
<span style={{fontSize:36,opacity:0.2}}></span>
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:10,textAlign:'center'}}>
Einstellungen wählen{String.fromCharCode(10)}dann Generieren"
</span>
</div>
)}
{generated && (
<div style={{display:'flex',gap:8}}>
<button onClick={download} style={{...S.btn('#ffe66d',true),fontSize:11,padding:'6px 14px'}}>
↓ PNG laden
</button>
<button onClick={save} disabled={busy}
style={{...S.btn('#4ecdc4'),fontSize:11,padding:'6px 14px',opacity:busy?0.5:1}}>
{busy?'…':editing?'✓ Aktualisieren':'💾 Speichern'}
</button>
</div>
)}
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,textAlign:'center',lineHeight:1.6}}>
QR-Code wird lokal generiert<br/>Nichts wird ohne Speichern gespeichert
Lokal generiert · Nichts wird ohne Speichern gespeichert
</div>
</div>
</div>
@@ -3123,25 +3095,48 @@ export default function DevTools({ toast, mobile }) {
const active = TOOL_DEFS.find(t=>t.id===activeTool);
const ActiveComp = active?.tabs ? active.components[activeTab] : active?.component;
// Emoji/Icon aus Label extrahieren
const getIcon = lbl => {
const m = lbl.match(/^([\u{1F300}-\u{1FFFF}\u{2600}-\u{27BF}⏱⚡📅🎨🔐🔍🌐◻±{}]/u);
return m ? m[0] : lbl[0];
};
const getText = lbl => lbl.replace(/^[\u{1F300}-\u{1FFFF}\u{2600}-\u{27BF}⏱⚡📅🎨🔐🔍🌐◻±{}]\s*/u,'').trim() || lbl;
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:860}}>
<div style={{display:'flex',alignItems:'center',gap:12,marginBottom:20,flexWrap:'wrap'}}>
<div style={{display:'flex',alignItems:'center',gap:12,marginBottom:16,flexWrap:'wrap'}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Dev-Tools</h1>
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10}}>Konverter · Formatter · Helfer</span>
</div>
<div style={{display:'flex',gap:7,flexWrap:'wrap',marginBottom:20}}>
{/* Tool-Auswahl: 2-spaltiges Grid */}
<div style={{display:'grid',gridTemplateColumns:'repeat(2,1fr)',gap:5,marginBottom:16}}>
{TOOL_DEFS.map(t=>(
<button key={t.id} title={t.desc}
onClick={()=>{ if(t.ready){setActiveTool(t.id);setActiveTab(t.tabs?.[0]?.id||'');} else toast('Kommt bald 🚧'); }}
style={{padding:'8px 14px',borderRadius:10,fontFamily:'monospace',fontSize:11,cursor:t.ready?'pointer':'default',
background:activeTool===t.id?'#4ecdc4':t.ready?'rgba(255,255,255,0.06)':'rgba(255,255,255,0.02)',
color: activeTool===t.id?'#0d0d0f':t.ready?'rgba(255,255,255,0.65)':'rgba(255,255,255,0.4)',
border: activeTool===t.id?'none':t.ready?'1px solid rgba(255,255,255,0.1)':'1px solid rgba(255,255,255,0.05)',
fontWeight: activeTool===t.id?700:400}}>
{t.label}{!t.ready&&<span style={{marginLeft:4,fontSize:8,opacity:0.4}}>bald</span>}
style={{
display:'flex', alignItems:'center', gap:9,
padding:'8px 11px', borderRadius:9, fontFamily:'monospace',
cursor:t.ready?'pointer':'default', textAlign:'left', border:'none',
background: activeTool===t.id ? 'rgba(78,205,196,0.15)' : t.ready ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.02)',
outline: activeTool===t.id ? '1px solid #4ecdc4' : 'none',
}}>
<span style={{fontSize:17,flexShrink:0,lineHeight:1,minWidth:22,textAlign:'center'}}>{getIcon(t.label)}</span>
<div style={{minWidth:0,flex:1}}>
<div style={{
color: activeTool===t.id ? '#4ecdc4' : t.ready ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.3)',
fontSize:12, fontWeight: activeTool===t.id?700:500,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}}>
{getText(t.label)}
</div>
<div style={{color:'rgba(255,255,255,0.28)',fontSize:9,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',marginTop:1}}>
{t.desc}
</div>
</div>
</button>
))}
</div>
<div style={{...S.card}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:14,flexWrap:'wrap'}}>
<div style={{...S.head,marginBottom:0}}>{active?.label}</div>