diff --git a/frontend/src/icons.jsx b/frontend/src/icons.jsx
index 4943e1e..e6b4b15 100644
--- a/frontend/src/icons.jsx
+++ b/frontend/src/icons.jsx
@@ -145,3 +145,8 @@ export const CodeIcon = ({size=20,color='currentColor',sw}) => base(<>
>, size, color, sw);
+export const SkizzeIcon = ({size=20,color='currentColor',sw}) => base(<>
+
+
+
+>, size, color, sw);
diff --git a/frontend/src/toolRegistry.js b/frontend/src/toolRegistry.js
index 52a3b5a..9b8793b 100644
--- a/frontend/src/toolRegistry.js
+++ b/frontend/src/toolRegistry.js
@@ -4,7 +4,8 @@ import Dateien from './tools/dateien.jsx';
import Nachrichten from './tools/nachrichten.jsx';
import Linkliste from './tools/linkliste.jsx';
import CodeSchnipsel from './tools/codeschnipsel.jsx';
-import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon } from './icons.jsx';
+import Skizze from './tools/skizze.jsx';
+import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon } from './icons.jsx';
export const TOOLS = [
{
@@ -63,6 +64,14 @@ export const TOOLS = [
group: 'Werkzeuge',
component: CodeSchnipsel,
},
+ {
+ id: 'skizze',
+ Icon: SkizzeIcon,
+ label: 'Skizzen-Rechner',
+ navLabel: 'Skizze',
+ group: 'Werkzeuge',
+ component: Skizze,
+ },
// Neues Tool: { id:'...', Icon:..., label:'...', navLabel:'...', group:'...', component:... },
];
diff --git a/frontend/src/tools/skizze.jsx b/frontend/src/tools/skizze.jsx
new file mode 100644
index 0000000..6e6b44a
--- /dev/null
+++ b/frontend/src/tools/skizze.jsx
@@ -0,0 +1,481 @@
+import { useState, useRef, useCallback, useEffect, useId } from 'react';
+import { api, S } from '../lib.js';
+
+// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
+const uid = () => Math.random().toString(36).slice(2,8);
+const clamp = (v,a,b) => Math.max(a, Math.min(b, v));
+const dist = (x1,y1,x2,y2) => Math.sqrt((x2-x1)**2+(y2-y1)**2);
+
+function snapToGrid(v, grid) { return grid ? Math.round(v / grid) * grid : v; }
+
+// Parse dimension string "225mm" → 225, or just "225" → 225
+function parseDim(s) {
+ if (!s) return null;
+ const n = parseFloat(String(s).replace(/[^0-9.,]/g,'').replace(',','.'));
+ return isNaN(n) ? null : n;
+}
+
+// Evaluate formula safely, variables = { name: value }
+function evalFormula(expr, vars) {
+ try {
+ const sanitized = expr.replace(/[^0-9+\-*/().,%a-zA-Z_\s]/g,'');
+ const keys = Object.keys(vars);
+ const vals = keys.map(k => vars[k]);
+ // eslint-disable-next-line no-new-func
+ return new Function(...keys, `"use strict"; return (${sanitized});`)(...vals);
+ } catch { return null; }
+}
+
+// Build variable map from shapes
+function buildVars(shapes) {
+ const vars = {};
+ shapes.forEach(s => {
+ const n = s.name?.replace(/[^a-zA-Z0-9_]/g,'_') || `_${s.id}`;
+ if (s.type === 'rect') {
+ const l = parseDim(s.dimW); const b = parseDim(s.dimH);
+ if (l != null) { vars[`${n}_l`] = l; vars[`${n}_b`] = b ?? l; }
+ if (l != null && b != null) { vars[`${n}_fl`] = l * b; vars[`${n}_um`] = 2*(l+b); }
+ }
+ if (s.type === 'line') {
+ const len = parseDim(s.dimLen);
+ if (len != null) vars[`${n}_len`] = len;
+ }
+ if (s.type === 'circle') {
+ const r = parseDim(s.dimR);
+ if (r != null) { vars[`${n}_r`] = r; vars[`${n}_d`] = r*2; vars[`${n}_fl`] = Math.PI*r*r; vars[`${n}_um`] = 2*Math.PI*r; }
+ }
+ });
+ return vars;
+}
+
+// ── DimensionLine Component ───────────────────────────────────────────────────
+function DimLine({ x1,y1,x2,y2,label,color='#4ecdc4',offset=18 }) {
+ const mx = (x1+x2)/2, my = (y1+y2)/2;
+ const dx = x2-x1, dy = y2-y1;
+ const len = Math.sqrt(dx*dx+dy*dy);
+ if (len<4) return null;
+ const nx = -dy/len*offset, ny = dx/len*offset;
+ const ax1=x1+nx, ay1=y1+ny, ax2=x2+nx, ay2=y2+ny;
+ return (
+
+
+
+
+ {label}
+
+ );
+}
+
+// ── Shape renderer ────────────────────────────────────────────────────────────
+function Shape({ s, selected, onClick }) {
+ const sel = selected === s.id;
+ const c = sel ? '#4ecdc4' : (s.color || '#7ecaff');
+
+ if (s.type === 'rect') {
+ const x = Math.min(s.x, s.x+s.w), y = Math.min(s.y, s.y+s.h);
+ const w = Math.abs(s.w), h = Math.abs(s.h);
+ return (
+ { e.stopPropagation(); onClick(s.id); }} style={{cursor:'pointer'}}>
+
+ {s.name &&
+ {s.name}
+ }
+ {s.dimW && }
+ {s.dimH && }
+ {sel && <>
+
+
+
+
+ >}
+
+ );
+ }
+
+ if (s.type === 'line') {
+ const len = dist(s.x1,s.y1,s.x2,s.y2);
+ return (
+ { e.stopPropagation(); onClick(s.id); }} style={{cursor:'pointer'}}>
+
+
+ {(s.dimLen||s.name) && len>20 && (
+
+ )}
+ {sel && <>
+
+
+ >}
+
+ );
+ }
+
+ if (s.type === 'circle') {
+ return (
+ { e.stopPropagation(); onClick(s.id); }} style={{cursor:'pointer'}}>
+
+ {s.name &&
+ {s.name}
+ }
+ {s.dimR && }
+ {sel && }
+
+ );
+ }
+
+ if (s.type === 'text') {
+ return (
+ { e.stopPropagation(); onClick(s.id); }} style={{cursor:'pointer'}}>
+
+ {s.label||'Text'}
+
+ {sel && }
+
+ );
+ }
+
+ return null;
+}
+
+// ── Properties Panel ──────────────────────────────────────────────────────────
+function PropsPanel({ shape, onChange, onDelete, vars }) {
+ if (!shape) return (
+
+ Form auswählen oder zeichnen
+
+ );
+
+ const Row = ({label, value, field, placeholder}) => (
+
+
{label}
+
onChange({[field]:e.target.value})}
+ placeholder={placeholder} autoCapitalize="none"
+ style={{...S.inp,fontSize:12,padding:'6px 8px'}}/>
+
+ );
+
+ return (
+
+
EIGENSCHAFTEN
+
|
+ {shape.type==='rect' && <>
+
|
+
|
+ >}
+ {shape.type==='line' &&
|
}
+ {shape.type==='circle' &&
|
}
+ {shape.type==='text' &&
|
}
+
+
+ {/* Variablen für diese Form */}
+ {shape.name && (
+
+
VERFÜGBARE VARIABLEN
+ {Object.entries(vars)
+ .filter(([k])=>k.startsWith((shape.name||'').replace(/[^a-zA-Z0-9_]/g,'_')+'_'))
+ .map(([k,v])=>(
+
+ {k}
+
+ {typeof v==='number'?v.toFixed(2):v}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ── Main Component ────────────────────────────────────────────────────────────
+const TOOLS_LIST = [
+ { id:'select', label:'▲', title:'Auswählen (V)' },
+ { id:'rect', label:'▭', title:'Rechteck (R)' },
+ { id:'line', label:'╱', title:'Linie (L)' },
+ { id:'circle', label:'○', title:'Kreis (C)' },
+ { id:'text', label:'T', title:'Text (T)' },
+];
+
+const GRID = 10;
+
+export default function Skizze({ toast, mobile }) {
+ const [shapes, setShapes] = useState([]);
+ const [selected, setSelected] = useState(null);
+ const [tool, setTool] = useState('select');
+ const [drawing, setDrawing] = useState(null);
+ const [formula, setFormula] = useState('');
+ const [results, setResults] = useState([]);
+ const [snapGrid, setSnapGrid] = useState(true);
+ const [sketches, setSketches] = useState([]);
+ const [sketchName, setSketchName] = useState('Skizze 1');
+ const [currentId, setCurrentId] = useState(null);
+ const svgRef = useRef(null);
+ const formulaRef = useRef(null);
+
+ const vars = buildVars(shapes);
+ const selShape = shapes.find(s=>s.id===selected) || null;
+
+ // Keyboard shortcuts
+ useEffect(()=>{
+ const h = e => {
+ if (e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return;
+ if (e.key==='v'||e.key==='V') setTool('select');
+ if (e.key==='r'||e.key==='R') setTool('rect');
+ if (e.key==='l'||e.key==='L') setTool('line');
+ if (e.key==='c'||e.key==='C') setTool('circle');
+ if (e.key==='t'||e.key==='T') setTool('text');
+ if (e.key==='Delete'||e.key==='Backspace') { if(selected) deleteSelected(); }
+ if (e.key==='Escape') { setSelected(null); setDrawing(null); }
+ };
+ window.addEventListener('keydown',h);
+ return ()=>window.removeEventListener('keydown',h);
+ },[selected, shapes]);
+
+ const getSVGPoint = useCallback((e) => {
+ const rect = svgRef.current?.getBoundingClientRect();
+ if (!rect) return {x:0,y:0};
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX;
+ const clientY = e.touches ? e.touches[0].clientY : e.clientY;
+ const x = clientX - rect.left;
+ const y = clientY - rect.top;
+ return {
+ x: snapGrid ? snapToGrid(x, GRID) : x,
+ y: snapGrid ? snapToGrid(y, GRID) : y,
+ };
+ },[snapGrid]);
+
+ const onSVGMouseDown = useCallback((e) => {
+ if (tool === 'select') { setSelected(null); return; }
+ const pt = getSVGPoint(e);
+ if (tool === 'text') {
+ setShapes(p=>[...p,{id:uid(),type:'text',x:pt.x,y:pt.y,label:'Text',name:''}]);
+ setTool('select');
+ return;
+ }
+ setDrawing({ tool, startX:pt.x, startY:pt.y, curX:pt.x, curY:pt.y });
+ },[tool, getSVGPoint]);
+
+ const onSVGMouseMove = useCallback((e) => {
+ if (!drawing) return;
+ const pt = getSVGPoint(e);
+ setDrawing(d=>({...d, curX:pt.x, curY:pt.y}));
+ },[drawing, getSVGPoint]);
+
+ const onSVGMouseUp = useCallback((e) => {
+ if (!drawing) return;
+ const pt = getSVGPoint(e);
+ const dx = pt.x - drawing.startX, dy = pt.y - drawing.startY;
+ if (Math.abs(dx)<4 && Math.abs(dy)<4) { setDrawing(null); return; }
+ let newShape;
+ if (drawing.tool==='rect') {
+ newShape = { id:uid(), type:'rect', x:drawing.startX, y:drawing.startY, w:dx, h:dy, name:'', dimW:'', dimH:'' };
+ } else if (drawing.tool==='line') {
+ newShape = { id:uid(), type:'line', x1:drawing.startX, y1:drawing.startY, x2:pt.x, y2:pt.y, name:'', dimLen:'' };
+ } else if (drawing.tool==='circle') {
+ const r = Math.sqrt(dx*dx+dy*dy);
+ newShape = { id:uid(), type:'circle', cx:drawing.startX, cy:drawing.startY, r, name:'', dimR:'' };
+ }
+ if (newShape) {
+ setShapes(p=>[...p, newShape]);
+ setSelected(newShape.id);
+ }
+ setDrawing(null);
+ },[drawing, getSVGPoint]);
+
+ const deleteSelected = () => {
+ setShapes(p=>p.filter(s=>s.id!==selected));
+ setSelected(null);
+ };
+
+ const updateShape = (patch) => {
+ setShapes(p=>p.map(s=>s.id===selected?{...s,...patch}:s));
+ };
+
+ const calcFormula = () => {
+ if (!formula.trim()) return;
+ const result = evalFormula(formula, vars);
+ const unit = formula.toLowerCase().includes('fl') ? 'mm²' : 'mm';
+ const entry = {
+ expr: formula,
+ result: result != null ? `${typeof result==='number'?result.toFixed(4).replace(/\.?0+$/,''):result} ${unit}` : '❌ Fehler',
+ ok: result != null,
+ };
+ setResults(p=>[entry,...p].slice(0,20));
+ };
+
+ // Preview shape while drawing
+ let preview = null;
+ if (drawing) {
+ const dx = drawing.curX-drawing.startX, dy = drawing.curY-drawing.startY;
+ if (drawing.tool==='rect') {
+ preview = ;
+ } else if (drawing.tool==='line') {
+ preview = ;
+ } else if (drawing.tool==='circle') {
+ const r = Math.sqrt(dx*dx+dy*dy);
+ preview = ;
+ }
+ }
+
+ const canvasH = mobile ? 280 : 420;
+
+ return (
+
+
+
+ Skizzen-Rechner
+
+
+
+
+
+
+
+ {/* Toolbar */}
+
+ {TOOLS_LIST.map(t=>(
+
+ ))}
+ {TOOLS_LIST.find(t2=>t2.id===tool)?.title}
+
+
+ {/* Main layout */}
+
+ {/* Canvas */}
+
+
+
+
+ {/* Right panel */}
+
+
+
+ {/* Formula bar */}
+
+
RECHNER
+
+ Beispiele: {Object.keys(vars).slice(0,4).join(' / ')||'(Formen zeichnen und benennen)'}
+
+
+ setFormula(e.target.value)}
+ onKeyDown={e=>e.key==='Enter'&&calcFormula()}
+ placeholder="z.B. Kasten_l / 2 oder Kasten_l * Kasten_b"
+ autoCapitalize="none"
+ style={{...S.inp,flex:1,fontFamily:"'Courier New',monospace",fontSize:13}}/>
+
+ {results.length>0 && (
+
+ )}
+
+
+ {/* Alle Variablen */}
+ {Object.keys(vars).length>0 && (
+
+
VARIABLEN
+
+ {Object.entries(vars).map(([k,v])=>(
+
+ setFormula(f=>f+k)}>{k}
+
+ = {typeof v==='number'?v.toFixed(2):v}
+
+
+ ))}
+
+
+ )}
+
+ {/* Ergebnisse */}
+ {results.map((r,i)=>(
+
+
+ {r.expr}
+
+
+ {r.result}
+
+
+ ))}
+
+ {results.length===0&&Object.keys(vars).length===0&&(
+
+ Zeichne Formen, benenne sie, dann kannst du hier damit rechnen.
+
+ )}
+
+
+ );
+}