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 */} +
+ + + {/* Grid */} + + + + + + + + + + {snapGrid && } + + {/* Shapes */} + {shapes.map(s=>( + { if(tool==='select') setSelected(id); }}/> + ))} + + {/* Preview */} + {preview} + +
+ + {/* 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. +
+ )} +
+
+ ); +}