import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import type { Theme, RibbonTab, ViewMode, RightPanel, DrawerTab, CursorPos, CommandHistoryEntry, KIMessage, KISuggestion, } from './types/ui.types'; import type { CADElement, CADLayer, BlockDefinition } from './types/cad.types'; import { createDefaultBlocks, BlockService } from './services/blockService'; import { SeatingService } from './services/seatingService'; import type { ToolState } from './interaction'; import { GroupManager, type ElementGroup } from './tools/modification/GroupTool'; import Topbar from './components/Topbar'; import RibbonBar from './components/RibbonBar'; import LeftSidebar from './components/LeftSidebar'; import CanvasArea from './components/CanvasArea'; import RightSidebar from './components/RightSidebar'; import CommandLine from './components/CommandLine'; import StatusBar from './components/StatusBar'; import MobileDrawers from './components/MobileDrawers'; import BackgroundImport from './components/BackgroundImport'; import HistoryPanel from './components/HistoryPanel'; import { BackgroundService, type BackgroundConfig } from './services/backgroundService'; import { HistoryManager, type CADStateSnapshot, type HistoryEntry } from './history'; import { getCommandRegistry } from './services/commandRegistry'; import { importFile, type ImportResult } from './services/importService'; import { exportProject, downloadBlob, type ExportFormat } from './services/exportService'; import type { ProjectData } from './types/cad.types'; import { loadProjectDataTyped, createElementTyped, updateElement, deleteElement as apiDeleteElement, createLayerTyped, updateLayer, deleteLayer as apiDeleteLayer, createBlockTyped, updateBlock, deleteBlock as apiDeleteBlock, aiChat } from './services/api'; import { useYjsBinding } from './crdt'; import { registerBuiltinPlugins, pluginRegistry } from './plugins'; import type { PluginContext } from './plugins'; import './styles.css'; import './styles/auth.css'; import { useAuth } from './contexts/AuthContext'; import { Login } from './pages/Login'; import { Register } from './pages/Register'; import { Dashboard } from './pages/Dashboard'; // ─── Mock Data ────────────────────────────────────────────── function makeInitialLayers(): CADLayer[] { const ts = Date.now(); return [ { id: `layer-${ts}-0`, name: 'Wände', visible: true, locked: false, color: '#e74c3c', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null }, { id: `layer-${ts}-1`, name: 'Türen', visible: true, locked: false, color: '#3498db', lineType: 'solid', transparency: 0, sortOrder: 1, parentId: null }, { id: `layer-${ts}-2`, name: 'Bestuhlung', visible: true, locked: false, color: '#2ecc71', lineType: 'solid', transparency: 0, sortOrder: 2, parentId: null }, { id: `layer-${ts}-3`, name: 'Bühne', visible: true, locked: false, color: '#f39c12', lineType: 'solid', transparency: 0, sortOrder: 3, parentId: null }, { id: `layer-${ts}-4`, name: 'Hintergrund', visible: true, locked: true, color: '#95a5a6', lineType: 'dotted', transparency: 50, sortOrder: 4, parentId: null }, ]; } const initialLayers: CADLayer[] = makeInitialLayers(); const initialBlocks: BlockDefinition[] = createDefaultBlocks(); const initialCommandHistory: CommandHistoryEntry[] = [ { prefix: '·', text: 'Bereit · Werkzeug: Auswahl · 110 Objekte · 5 Ebenen', type: 'info' }, { prefix: '·', text: 'Auto-Save aktiv · letzte Speicherung vor 3 Sekunden', type: 'info' }, ]; const initialKIMessages: KIMessage[] = [ { id: 'ki-1', role: 'assistant', content: 'Hallo! Ich bin der KI Copilot. Wie kann ich helfen?' }, ]; const initialKISuggestions: KISuggestion[] = [ { id: 'sug-1', label: 'Bestuhlung automatisch generieren' }, { id: 'sug-2', label: 'Maße analysieren' }, { id: 'sug-3', label: 'Flächen berechnen' }, ]; // Prevent duplicate drawing creation across StrictMode double-render const loadedProjects = new Set(); // ─── CAD Editor Component ─────────────────────────────── interface CADEditorProps { projectId: string; token: string; onNavigateBack: () => void; } const CADEditor: React.FC = ({ projectId, token, onNavigateBack }) => { // Theme const [theme, setTheme] = useState('dark'); // Auth user for collaboration const { user } = useAuth(); // Yjs collaboration binding const collab = useYjsBinding({ docName: `project-${projectId}`, userId: user?.id || 'anonymous', userName: user?.name || 'Gast', userColor: '#3498db', enabled: true, }); // Project const [projectName, setProjectName] = useState('Unbenannt'); const [savedStatus, setSavedStatus] = useState('Lädt…'); const [drawingId, setDrawingId] = useState(null); // Ribbon const [activeRibbonTab, setActiveRibbonTab] = useState('start'); // Tools const [activeTool, setActiveTool] = useState('select'); // Canvas / View const [viewMode, setViewMode] = useState('2d'); const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); const [gridEnabled, setGridEnabled] = useState(true); const [orthoEnabled, setOrthoEnabled] = useState(false); const [snapEnabled, setSnapEnabled] = useState(true); const [polarEnabled, setPolarEnabled] = useState(false); // Right sidebar const [activeRightPanel, setActiveRightPanel] = useState('tool'); const [selectedElement, setSelectedElement] = useState(null); const [layers, setLayers] = useState(initialLayers); const [activeLayerId, setActiveLayerId] = useState(initialLayers[0].id); const [blocks, setBlocks] = useState(initialBlocks); const [blockCategory, setBlockCategory] = useState('Alle'); const [selectedTemplate, setSelectedTemplate] = useState(null); // Command line const [commandHistory, setCommandHistory] = useState(initialCommandHistory); // KI Copilot const [kiMessages, setKIMessages] = useState(initialKIMessages); const [kiSuggestions] = useState(initialKISuggestions); const [kiLoading, setKiLoading] = useState(false); // Plugin system useEffect(() => { const ctx: PluginContext = { addElement: (el) => setElements((prev) => [...prev, el]), removeElement: (id) => setElements((prev) => prev.filter((e) => e.id !== id)), updateElement: (id, props) => setElements((prev) => prev.map((e) => (e.id === id ? { ...e, ...props } : e))), getElements: () => elements, getLayers: () => layers, getActiveLayerId: () => activeLayerId, showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]), log: (msg) => console.log(`[Plugin] ${msg}`), }; pluginRegistry.setContext(ctx); registerBuiltinPlugins(); pluginRegistry.initDefaults(); }, []); // Status bar const onlineCount = collab.cursors.length + 1; // Mobile drawers const [mobileLeftOpen, setMobileLeftOpen] = useState(false); const [mobileRightOpen, setMobileRightOpen] = useState(false); const [activeDrawerTab, setActiveDrawerTab] = useState('tool'); // Background const [bgImportOpen, setBgImportOpen] = useState(false); const [bgConfig, setBgConfig] = useState(null); const bgServiceRef = React.useRef(new BackgroundService()); // History panel const [historyPanelOpen, setHistoryPanelOpen] = useState(false); // Elements + undo/redo (HistoryManager) const [elements, setElements] = useState([]); const historyManagerRef = React.useRef(new HistoryManager()); const [historyEntries, setHistoryEntries] = useState([]); const [toolState, setToolState] = useState(null); const seatCount = useMemo(() => { const svc = new SeatingService(); return svc.countSeats(elements).total; }, [elements]); // Groups const groupManagerRef = React.useRef(new GroupManager()); const [groups, setGroups] = useState([]); // Clipboard (for copy/paste) const clipboardRef = React.useRef(null); // ─── Handlers ─────────────────────────────────────────── const handleThemeToggle = useCallback(() => { setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); }, []); const syncHistory = useCallback(() => { setHistoryEntries(historyManagerRef.current.getHistory()); }, []); const restoreSnapshot = useCallback((snap: CADStateSnapshot) => { setElements(snap.elements); setLayers(snap.layers); setBlocks(snap.blocks); setGroups(snap.groups); setBgConfig(snap.bgConfig); }, []); const handleUndo = useCallback(() => { const snap = historyManagerRef.current.undo(); if (snap) { restoreSnapshot(snap); syncHistory(); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rückgängig: letzte Aktion', type: 'info' }]); } }, [restoreSnapshot, syncHistory]); const handleRedo = useCallback(() => { const snap = historyManagerRef.current.redo(); if (snap) { restoreSnapshot(snap); syncHistory(); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Wiederherstellen: letzte Aktion', type: 'info' }]); } }, [restoreSnapshot, syncHistory]); const pushHistorySnapshot = useCallback((label: string) => { historyManagerRef.current.pushSnapshot({ elements, layers, blocks, groups, bgConfig, }, label); syncHistory(); }, [elements, layers, blocks, groups, bgConfig, syncHistory]); // Initialize HistoryManager with initial state on mount const historyInitRef = React.useRef(false); React.useEffect(() => { if (historyInitRef.current) return; historyInitRef.current = true; historyManagerRef.current.initialize({ elements: [], layers: initialLayers, blocks: initialBlocks, groups: [], bgConfig: null, }); syncHistory(); }, [syncHistory]); // Load project data from backend on mount const [dataLoaded, setDataLoaded] = useState(false); React.useEffect(() => { if (!projectId || !token) return; let cancelled = false; (async () => { try { setSavedStatus('Lädt…'); const data = await loadProjectDataTyped(token, projectId); if (cancelled) return; setProjectName(data.project.name); setDrawingId(data.drawing?.id || null); if (data.elements.length > 0) setElements(data.elements); if (data.layers.length > 0) setLayers(data.layers); if (data.blocks.length > 0) setBlocks(data.blocks); // Save initial layers to backend if backend has none if (data.layers.length === 0 && data.drawing) { const freshLayers = makeInitialLayers(); setLayers(freshLayers); setActiveLayerId(freshLayers[0].id); for (const layer of freshLayers) { createLayerTyped(token, data.drawing.id, layer).catch(() => {}); } } else { setActiveLayerId(data.layers[0]?.id ?? initialLayers[0].id); } setSavedStatus('gespeichert'); setDataLoaded(true); // Push initial data to Yjs CRDT after load if (collab.status === 'connected') { collab.loadFromState({ elements: data.elements, layers: data.layers, blocks: data.blocks }); } } catch (err) { console.error('Failed to load project:', err); setSavedStatus('Fehler beim Laden'); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, token]); // Sync remote → local: when Yjs data changes from other users, update local state React.useEffect(() => { if (collab.status !== 'connected') return; // Skip if collab data is empty (not yet loaded) if (collab.elements.length === 0 && collab.layers.length === 0 && collab.blocks.length === 0) return; // Only update local state if remote data differs AND local is not ahead of remote // (prevents race condition where local element is overwritten by stale Yjs sync) const localIds = new Set(elements.map(e => e.id)); const remoteIds = new Set(collab.elements.map(e => e.id)); const localHasExtra = elements.some(e => !remoteIds.has(e.id)); const remoteHasExtra = collab.elements.some(e => !localIds.has(e.id)); // Only sync from remote if remote has elements local doesn't have AND local doesn't have unsaved elements if (remoteHasExtra && !localHasExtra) { setElements(collab.elements); } else if (remoteHasExtra && localHasExtra) { // Merge: keep local elements, update any that changed remotely const merged = elements.map(el => { const remote = collab.elements.find(e => e.id === el.id); return remote || el; }); // Add remote-only elements collab.elements.forEach(el => { if (!localIds.has(el.id)) merged.push(el); }); setElements(merged); } const sameLayers = collab.layers.length === layers.length && collab.layers.every((l, i) => l.id === layers[i]?.id); if (!sameLayers) setLayers(collab.layers); const sameBlocks = collab.blocks.length === blocks.length && collab.blocks.every((b, i) => b.id === blocks[i]?.id); if (!sameBlocks) setBlocks(collab.blocks); // eslint-disable-next-line react-hooks/exhaustive-deps }, [collab.elements, collab.layers, collab.blocks, collab.status]); const handleElementCreated = useCallback((el: CADElement) => { const elWithLayer = el.layerId ? el : { ...el, layerId: activeLayerId }; setElements((prev) => { const newElements = [...prev, elWithLayer]; historyManagerRef.current.pushSnapshot({ elements: newElements, layers, blocks, groups, bgConfig, }, 'Element erstellt'); return newElements; }); syncHistory(); // Push to Yjs CRDT for real-time sync collab.setElement(elWithLayer); // Save to backend if (drawingId && token) { setSavedStatus('Speichert…'); createElementTyped(token, drawingId, elWithLayer).then(() => { setSavedStatus('gespeichert'); }).catch((err) => { console.error('Failed to save element:', err); setSavedStatus('Fehler beim Speichern'); }); } }, [layers, blocks, groups, bgConfig, syncHistory, drawingId, token, collab, activeLayerId]); const handleElementsDeleted = useCallback((ids: string[]) => { setElements((prev) => { const newElements = prev.filter((e) => !ids.includes(e.id)); historyManagerRef.current.pushSnapshot({ elements: newElements, layers, blocks, groups, bgConfig, }, `${ids.length} Element(e) gelöscht`); return newElements; }); syncHistory(); // Push deletions to Yjs CRDT for real-time sync ids.forEach(id => collab.deleteElement(id)); // Delete from backend if (token) { setSavedStatus('Speichert…'); Promise.all(ids.map(id => apiDeleteElement(token, id))).then(() => { setSavedStatus('gespeichert'); }).catch((err) => { console.error('Failed to delete elements:', err); setSavedStatus('Fehler beim Speichern'); }); } }, [elements, layers, blocks, groups, bgConfig, syncHistory, token, collab]); const handleElementsModified = useCallback((modified: CADElement[]) => { setElements((prev) => { const newElements = prev.map((e) => { const found = modified.find((m) => m.id === e.id); return found ?? e; }); historyManagerRef.current.pushSnapshot({ elements: newElements, layers, blocks, groups, bgConfig, }, `${modified.length} Element(e) geändert`); return newElements; }); syncHistory(); // Push modifications to Yjs CRDT for real-time sync modified.forEach(el => collab.setElement(el)); // Update in backend if (token) { setSavedStatus('Speichert…'); Promise.all(modified.map(el => updateElement(token, el.id, el))).then(() => { setSavedStatus('gespeichert'); }).catch((err) => { console.error('Failed to update elements:', err); setSavedStatus('Fehler beim Speichern'); }); } }, [layers, blocks, groups, bgConfig, syncHistory, token, collab]); const lastCursorUpdateRef = useRef(0); const handleCursorMoved = useCallback((x: number, y: number) => { const now = performance.now(); if (now - lastCursorUpdateRef.current < 33) return; // throttle to ~30fps lastCursorUpdateRef.current = now; setCursorPos({ x, y }); collab.setCursor(x, y); }, [collab]); const handleToolStateChanged = useCallback((state: ToolState) => { setToolState(state); }, []); const handleTextEdit = useCallback((el: CADElement) => { const text = window.prompt('Text eingeben:', ''); if (text !== null && text.trim() !== '') { setElements((prev) => prev.map((e) => e.id === el.id ? { ...e, properties: { ...e.properties, text } } : e )); } }, []); const handleCommandTrigger = useCallback((msg: string) => { setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]); }, []); // Block management handlers const handleRenameBlock = useCallback((id: string, name: string) => { setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, name } : b)); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block umbenannt: ${name}`, type: 'info' }]); }, []); const handleDuplicateBlock = useCallback((id: string) => { setBlocks((prev) => { const block = prev.find((b) => b.id === id); if (!block) return prev; const newId = `blk-${Date.now()}`; const copy: BlockDefinition = { ...block, id: newId, name: `${block.name} (Kopie)`, elements: block.elements.map((el) => ({ ...el, id: `${el.id}_copy_${Date.now()}` })), }; return [...prev, copy]; }); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block dupliziert', type: 'info' }]); }, []); const handleDeleteBlock = useCallback((id: string) => { setBlocks((prev) => prev.filter((b) => b.id !== id)); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block gelöscht', type: 'info' }]); // Delete from backend if (token) { apiDeleteBlock(token, id).catch((err) => { console.error('Failed to delete block:', err); }); } }, [token]); const handleSvgImport = useCallback((svg: string, name: string, category: string) => { const svc = new BlockService(); const block = svc.importSVG(svg, name, category); setBlocks((prev) => [...prev, block]); setCommandHistory((prev) => [...prev, { prefix: '·', text: `SVG importiert: ${name}`, type: 'info' }]); }, []); const handleSaveGroupAsBlock = useCallback((name: string) => { const selectedEls = selectedElement ? [selectedElement] : []; setBlocks((prev) => { const newId = `blk_grp_${Date.now()}`; const block: BlockDefinition = { id: newId, name, description: 'Aus Auswahl erstellt', category: 'Custom', elements: selectedEls.map(el => ({ ...el, id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` })), }; return [...prev, block]; }); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block erstellt: ${name} (${selectedEls.length} Elemente)`, type: 'info' }]); }, [selectedElement]); const handleBlockCategoryChange = useCallback((cat: string) => { setBlockCategory(cat); }, []); const handleTemplateSelect = useCallback((templateName: string | null) => { setSelectedTemplate(templateName); if (templateName) { setCommandHistory((prev) => [...prev, { prefix: '·', text: `Vorlage gewählt: ${templateName} – Klicken zum Platzieren`, type: 'info' }]); } }, []); const handleBlockSearch = useCallback((_query: string) => { // Search is handled locally in BlockLibrary component }, []); const handleDragBlock = useCallback((blockId: string) => { setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block gezogen: ${blockId}`, type: 'info' }]); }, []); const handleBlockDrop = useCallback((blockId: string, x: number, y: number) => { const svc = new BlockService(); blocks.forEach(b => svc.addBlock(b)); const instance = svc.createInstance(blockId, x, y, activeLayerId); if (instance) { setElements((prev) => [...prev, instance]); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block platziert: ${blockId} bei (${x.toFixed(2)}, ${y.toFixed(2)})`, type: 'info' }]); } }, [blocks, activeLayerId]); const handleSelectionChange = useCallback((selectedIds: string[]) => { if (selectedIds.length === 1) { const el = elements.find(e => e.id === selectedIds[0]); setSelectedElement(el ?? null); } else { setSelectedElement(null); } }, [elements]); const handleImport = useCallback(async (file: File) => { setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiere ${file.name}...`, type: 'info' }]); const result = await importFile(file); if (result.success) { setElements((prev) => [...prev, ...result.elements]); if (result.layers && result.layers.length > 0) { setLayers((prev) => { const existingIds = new Set(prev.map(l => l.id)); const newLayers = result.layers!.filter(l => !existingIds.has(l.id)); return [...prev, ...newLayers]; }); } if (result.blocks && result.blocks.length > 0) { setBlocks((prev) => [...prev, ...result.blocks!]); } setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiert: ${result.elements.length} Elemente aus ${file.name}`, type: 'info' }]); if (result.warnings.length > 0) { result.warnings.forEach(w => setCommandHistory((prev) => [...prev, { prefix: '·', text: w, type: 'info' }])); } } else { setCommandHistory((prev) => [...prev, { prefix: '·', text: `Import fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]); } }, []); const handleExport = useCallback(async (format: ExportFormat) => { const projectData: ProjectData = { version: '1.0', name: projectName, layers, elements, blocks, }; setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiere als ${format.toUpperCase()}...`, type: 'info' }]); const result = await exportProject(projectData, { format }); if (result.success && result.blob) { downloadBlob(result.blob, result.filename); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiert: ${result.filename}`, type: 'info' }]); } else { setCommandHistory((prev) => [...prev, { prefix: '·', text: `Export fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]); } }, [layers, elements, blocks]); const handleRibbonAction = useCallback((action: string) => { setCommandHistory((prev) => [...prev, { prefix: '›', text: action, type: 'command' }]); // ─── File actions ─── if (action === 'new') { onNavigateBack(); return; } if (action === 'open') { onNavigateBack(); return; } if (action === 'save') { setSavedStatus('Gespeichert'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Projekt gespeichert', type: 'info' }]); // Save elements to backend if drawingId exists if (drawingId && token) { elements.forEach((el) => { if (!el.id.startsWith('el-')) return; updateElement(token, el.id, el).catch(() => {}); }); } return; } if (action === 'import') { const input = document.createElement('input'); input.type = 'file'; input.accept = '.dxf,.svg,.json'; input.onchange = () => { if (input.files && input.files[0]) { handleImport(input.files[0]); } }; input.click(); return; } if (action === 'export') { const input = document.createElement('input'); input.type = 'file'; input.setAttribute('nwsave', ''); input.accept = '.dxf,.svg,.pdf,.png,.json'; input.onchange = () => { const name = input.value || 'cad-export'; const ext = name.split('.').pop()?.toLowerCase() as ExportFormat; if (ext && ['dxf', 'svg', 'pdf', 'png', 'json'].includes(ext)) { handleExport(ext); } else { handleExport('dxf'); } }; input.click(); return; } // ─── Edit actions ─── if (action === 'undo') { handleUndo(); return; } if (action === 'redo') { handleRedo(); return; } if (action === 'copy') { const selected = elements.filter((e) => (e as any).selected); const clip = selected.length > 0 ? selected : elements.slice(0, 1); clipboardRef.current = clip; setCommandHistory((prev) => [...prev, { prefix: '·', text: `${clip.length} Element(e) kopiert`, type: 'info' }]); return; } if (action === 'paste') { if (clipboardRef.current && clipboardRef.current.length > 0) { const offset = 20; const pasted = clipboardRef.current.map((el, i) => ({ ...el, id: `el-${Date.now()}-${i}`, x: (el as any).x ? (el as any).x + offset : undefined, y: (el as any).y ? (el as any).y + offset : undefined, })); setElements((prev) => [...prev, ...pasted]); setCommandHistory((prev) => [...prev, { prefix: '·', text: `${pasted.length} Element(e) eingefügt`, type: 'info' }]); } else { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zwischenablage leer', type: 'info' }]); } return; } // ─── Insert actions (set active tool) ─── if (action === 'insert-line') { setActiveTool('line'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Linie-Werkzeug aktiv', type: 'info' }]); return; } if (action === 'insert-rect') { setActiveTool('rect'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rechteck-Werkzeug aktiv', type: 'info' }]); return; } if (action === 'insert-circle') { setActiveTool('circle'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Kreis-Werkzeug aktiv', type: 'info' }]); return; } if (action === 'insert-text') { setActiveTool('text'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Text-Werkzeug aktiv', type: 'info' }]); return; } if (action === 'insert-freehand') { setActiveTool('polyline'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Freihand-Werkzeug aktiv', type: 'info' }]); return; } if (action === 'insert-image') { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.onchange = () => { if (input.files && input.files[0]) { const reader = new FileReader(); reader.onload = () => { const imgEl: CADElement = { id: `el-${Date.now()}`, type: 'image' as any, layerId: activeLayerId, x: 0, y: 0, properties: { src: reader.result as string, width: 200, height: 200 }, } as any; setElements((prev) => [...prev, imgEl]); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Bild eingefügt', type: 'info' }]); }; reader.readAsDataURL(input.files[0]); } }; input.click(); return; } // ─── Format actions ─── if (action.startsWith('format-')) { setCommandHistory((prev) => [...prev, { prefix: '·', text: `Format: ${action.replace('format-', '')} (Auswahl erforderlich)`, type: 'info' }]); return; } // ─── View actions ─── if (action === 'zoom-fit') { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]); return; } if (action === 'zoom-100') { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: 100%', type: 'info' }]); return; } if (action === 'grid') { setGridEnabled((p) => !p); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Grid ${gridEnabled ? 'aus' : 'ein'}`, type: 'info' }]); return; } if (action === 'layer') { setActiveRightPanel('layer'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Layer-Manager geöffnet', type: 'info' }]); return; } // ─── Tools actions ─── if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; } if (action === 'search') { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Suche: Befehlszeile verwenden (Strg+F)', type: 'info' }]); return; } if (action === 'plugins') { setActiveRightPanel('plugins'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Plugin-Manager geöffnet', type: 'info' }]); return; } if (action === 'history') { setHistoryPanelOpen((prev) => !prev); return; } // ─── Background ─── if (action === 'background-import') { setBgImportOpen(true); return; } // ─── KI actions ─── if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; } if (action === 'ki-draw') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Zeichnen: Beschreibung eingeben', type: 'info' }]); return; } if (action === 'ki-analyze') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Analyse gestartet', type: 'info' }]); return; } }, [handleUndo, handleRedo, handleImport, handleExport, onNavigateBack, drawingId, token, elements, activeLayerId, gridEnabled]); const handleToolChange = useCallback((tool: string) => { setActiveTool(tool); setMobileLeftOpen(false); }, []); const handleViewChange = useCallback((mode: ViewMode) => { setViewMode(mode); }, []); const handleToggleGrid = useCallback(() => setGridEnabled((p) => !p), []); const handleToggleOrtho = useCallback(() => setOrthoEnabled((p) => !p), []); const handleToggleSnap = useCallback(() => setSnapEnabled((p) => !p), []); const handleTogglePolar = useCallback(() => setPolarEnabled((p) => !p), []); // Layer handlers const handleSelectLayer = useCallback((id: string) => setActiveLayerId(id), []); const handleAddLayer = useCallback(() => { setLayers((prev) => { const newId = `layer-${Date.now()}`; const newLayer: CADLayer = { id: newId, name: `Layer ${prev.length + 1}`, visible: true, locked: false, color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: prev.length, parentId: null, }; // Save to backend if (drawingId && token) { createLayerTyped(token, drawingId, newLayer).catch((err) => { console.error('Failed to save layer:', err); }); } return [...prev, newLayer]; }); }, [drawingId, token]); const handleToggleLayer = useCallback((id: string) => { setLayers((prev) => prev.map((l) => l.id === id ? { ...l, visible: !l.visible } : l)); }, []); const handleDeleteLayer = useCallback((id: string) => { setLayers((prev) => prev.filter((l) => l.id !== id)); // Delete from backend if (token) { apiDeleteLayer(token, id).catch((err) => { console.error('Failed to delete layer:', err); }); } }, [token]); const handleRenameLayer = useCallback((id: string, name: string) => { setLayers((prev) => prev.map((l) => l.id === id ? { ...l, name } : l)); }, []); const handleDuplicateLayer = useCallback((id: string) => { setLayers((prev) => { const layer = prev.find((l) => l.id === id); if (!layer) return prev; const newId = `layer-${Date.now()}`; const copy: CADLayer = { ...layer, id: newId, name: `${layer.name} (Kopie)`, sortOrder: prev.length }; return [...prev, copy]; }); }, []); const handleToggleLock = useCallback((id: string) => { setLayers((prev) => prev.map((l) => l.id === id ? { ...l, locked: !l.locked } : l)); }, []); const handleUpdateLayerColor = useCallback((id: string, color: string) => { setLayers((prev) => prev.map((l) => l.id === id ? { ...l, color } : l)); }, []); const handleUpdateLayerLineType = useCallback((id: string, lineType: 'solid' | 'dashed' | 'dotted') => { setLayers((prev) => prev.map((l) => l.id === id ? { ...l, lineType } : l)); }, []); const handleUpdateLayerTransparency = useCallback((id: string, transparency: number) => { setLayers((prev) => prev.map((l) => l.id === id ? { ...l, transparency } : l)); }, []); const handleZoomIn = useCallback(() => { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: vergrößert', type: 'info' }]); }, []); const handleZoomOut = useCallback(() => { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: verkleinert', type: 'info' }]); }, []); const handleZoomFit = useCallback(() => { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]); }, []); const handleBgApply = useCallback((config: BackgroundConfig, _image: HTMLImageElement | null) => { setBgConfig(config); setBgImportOpen(false); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Hintergrund geladen: ${config.name} (${config.width}×${config.height}px, Maßstab: ${config.scale.toFixed(3)} px/mm)`, type: 'info' }]); }, []); const handleCommand = useCallback((cmd: string) => { const upper = cmd.trim().toUpperCase(); setCommandHistory((prev) => [...prev, { prefix: '›', text: cmd, type: 'command' }]); const registry = getCommandRegistry(); if (upper === 'UNDO' || upper === 'U') { handleUndo(); return; } if (upper === 'REDO') { handleRedo(); return; } // Group command: create group from currently selected elements if (upper === 'GROUP' || upper === 'GRP') { const gm = groupManagerRef.current; // For now, group all elements (selection state is in InteractionEngine, not accessible here) // In a full implementation, we'd need selected IDs from the interaction engine setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe erstellt (Auswahl im Canvas erforderlich)', type: 'info' }]); setGroups(gm.getGroups()); return; } // Ungroup command if (upper === 'UNG' || upper === 'UNGROUP') { const gm = groupManagerRef.current; const allGroups = gm.getGroups(); if (allGroups.length > 0) { gm.ungroup(allGroups[allGroups.length - 1].id); setGroups(gm.getGroups()); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe aufgelöst', type: 'info' }]); } return; } // Import command — trigger file dialog if (upper === 'IMPORT' || upper === 'IMP' || upper === 'I') { handleRibbonAction('import'); return; } // Export command — trigger export dialog if (upper === 'EXPORT' || upper === 'EXP' || upper === 'EX') { handleRibbonAction('export'); return; } const tool = registry.getToolId(upper); if (tool) { setActiveTool(tool); setMobileLeftOpen(false); const label = registry.getLabel(upper) ?? `Werkzeug: ${tool}`; setCommandHistory((prev) => [...prev, { prefix: '·', text: label, type: 'info' }]); } else { // Check plugin commands const pluginCmds = pluginRegistry.getCommandExtensions(); const parts = cmd.trim().split(/\s+/); const cmdName = parts[0].toUpperCase(); const pluginCmd = pluginCmds.find((c) => c.name.toUpperCase() === cmdName); if (pluginCmd) { const ctx: PluginContext = { addElement: (el) => setElements((prev) => [...prev, el]), removeElement: (id) => setElements((prev) => prev.filter((e) => e.id !== id)), updateElement: (id, props) => setElements((prev) => prev.map((e) => (e.id === id ? { ...e, ...props } : e))), getElements: () => elements, getLayers: () => layers, getActiveLayerId: () => activeLayerId, showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]), log: (msg) => console.log(`[Plugin] ${msg}`), }; pluginCmd.execute(parts.slice(1), ctx); } else { setCommandHistory((prev) => [...prev, { prefix: '·', text: `Unbekannter Befehl: ${cmd}`, type: 'info' }]); } } }, [handleUndo, handleRedo, handleRibbonAction]); const handleKISend = useCallback(async (text: string) => { const userMsg: KIMessage = { id: `ki-${Date.now()}`, role: 'user', content: text }; const pendingId = `ki-${Date.now() + 1}`; const pendingMsg: KIMessage = { id: pendingId, role: 'assistant', content: '…' }; setKIMessages((prev) => [...prev, userMsg, pendingMsg]); setKiLoading(true); try { // Build CAD context const elementTypeSummary: Record = {}; for (const el of elements) { elementTypeSummary[el.type] = (elementTypeSummary[el.type] || 0) + 1; } const context = { projectName, elementCount: elements.length, layerCount: layers.length, elementTypeSummary, }; // Build message history (last 10 messages) const history = kiMessages.slice(-10).map((m) => ({ role: m.role, content: typeof m.content === 'string' ? m.content : String(m.content), })); history.push({ role: 'user', content: text }); const result = await aiChat(token, history, context); setKIMessages((prev) => prev.map((m) => m.id === pendingId ? { ...m, content: result.content } : m ) ); } catch (err: any) { setKIMessages((prev) => prev.map((m) => m.id === pendingId ? { ...m, content: `Fehler: ${err.message || 'KI-Anfrage fehlgeschlagen'}` } : m ) ); } finally { setKiLoading(false); } }, [token, projectName, elements, layers, kiMessages]); const handleSuggestionClick = useCallback((suggestion: KISuggestion) => { handleKISend(suggestion.label); }, [handleKISend]); const activeLayerName = layers.find((l) => l.id === activeLayerId)?.name ?? '—'; // ─── Render ───────────────────────────────────────────── return (
setMobileLeftOpen(false)} onCloseRight={() => setMobileRightOpen(false)} onRightTabChange={setActiveDrawerTab} /> setBgImportOpen(false)} onApply={handleBgApply} backgroundService={bgServiceRef.current} /> {historyPanelOpen && ( { const snap = historyManagerRef.current.jumpTo(entryId); if (snap) { restoreSnapshot(snap); syncHistory(); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Historie: zu „${snap.label}“ gesprungen`, type: 'info' }]); } }} onClose={() => setHistoryPanelOpen(false)} /> )}
); }; // ─── App Wrapper (Auth Gate) ─────────────────────────── const App: React.FC = () => { const { user, token } = useAuth(); const [authView, setAuthView] = useState<'login' | 'register'>('login'); const [openedProjectId, setOpenedProjectId] = useState(null); // Not authenticated → show Login or Register if (!user || !token) { return authView === 'login' ? setAuthView('register')} /> : setAuthView('login')} />; } // Authenticated but no project opened → show Dashboard if (!openedProjectId) { return setOpenedProjectId(id)} />; } // Authenticated + project opened → show CAD Editor return setOpenedProjectId(null)} />; }; export default App;