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, ImageElement } from './types/cad.types'; import { 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 SettingsModal from './components/SettingsModal'; import InlineTextEditor from './components/InlineTextEditor'; 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, getSetting, setSetting } from './services/api'; import { useYjsBinding } from './crdt'; import { registerBuiltinPlugins, pluginRegistry } from './plugins'; import type { PluginContext } from './plugins'; import type { UnitType } from './utils/format'; 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'; // ─── Initial State ────────────────────────────────────────── // Data is loaded from the backend on mount — start with empty state. const initialLayers: CADLayer[] = []; const initialBlocks: BlockDefinition[] = []; const initialCommandHistory: CommandHistoryEntry[] = [ { prefix: '·', text: 'Bereit', 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' }, ]; // ─── 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, token: token || undefined, }); // 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); // Units const [unit, setUnit] = useState('mm'); const [gridSize, setGridSize] = useState(20); const [scaleFactor, setScaleFactor] = useState(1); // Canvas appearance const [canvasBgColor, setCanvasBgColor] = useState('#1e1e2e'); const [gridColor, setGridColor] = useState('#3a3a4a'); const [rulerEnabled, setRulerEnabled] = useState(true); // Right sidebar const [activeRightPanel, setActiveRightPanel] = useState('tool'); const [selectedElement, setSelectedElement] = useState(null); const [selectedElementIds, setSelectedElementIds] = useState([]); const [layers, setLayers] = useState(initialLayers); const [activeLayerId, setActiveLayerId] = useState('layer-0'); 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 const createPluginContext = useCallback((): 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: () => elementsRef.current, getLayers: () => layersRef.current, getActiveLayerId: () => activeLayerIdRef.current, showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]), log: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: `[Plugin] ${msg}`, type: 'info' }]), }), []); useEffect(() => { pluginRegistry.setContext(createPluginContext()); registerBuiltinPlugins(); pluginRegistry.initDefaults(); }, [createPluginContext]); // Status bar – only count self as online when actually connected const onlineCount = collab.status === 'connected' ? collab.cursors.length : 0; // Mobile drawers const [mobileLeftOpen, setMobileLeftOpen] = useState(false); const [mobileRightOpen, setMobileRightOpen] = useState(false); // Desktop sidebar collapse const [leftSidebarCollapsed, setLeftSidebarCollapsed] = useState(false); const [leftbarWidth, setLeftbarWidth] = useState(200); const [rightbarWidth, setRightbarWidth] = useState(300); const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [activeDrawerTab, setActiveDrawerTab] = useState('tool'); // Background const [bgImportOpen, setBgImportOpen] = useState(false); const [bgConfig, setBgConfig] = useState(null); const bgServiceRef = React.useRef(new BackgroundService()); // Cleanup BackgroundService on unmount useEffect(() => { return () => { bgServiceRef.current.dispose(); }; }, []); // History panel const [historyPanelOpen, setHistoryPanelOpen] = useState(false); // Export format selector (cross-browser replacement for nwsave) const [exportFormatOpen, setExportFormatOpen] = useState(false); // Zoom command state — triggers zoom actions in CanvasArea via useEffect const [zoomCommand, setZoomCommand] = useState<'in' | 'out' | 'fit' | '100' | null>(null); // Elements + undo/redo (HistoryManager) const [elements, setElements] = useState([]); const historyManagerRef = React.useRef(new HistoryManager()); const [historyEntries, setHistoryEntries] = useState([]); const [toolState, setToolState] = useState(null); const seatingServiceRef = useRef(new SeatingService()); const seatCount = useMemo(() => { return seatingServiceRef.current.countSeats(elements).total; }, [elements]); // Groups const groupManagerRef = React.useRef(new GroupManager()); const [groups, setGroups] = useState([]); // Issue #8: Refs for PluginContext to avoid stale closures. // These refs are updated on every render so PluginContext getters always return current state. const elementsRef = useRef(elements); elementsRef.current = elements; const layersRef = useRef(layers); layersRef.current = layers; const blocksRef = useRef(blocks); blocksRef.current = blocks; const groupsRef = useRef(groups); groupsRef.current = groups; const bgConfigRef = useRef(bgConfig); bgConfigRef.current = bgConfig; const activeLayerIdRef = useRef(activeLayerId); activeLayerIdRef.current = activeLayerId; const selectedElementIdsRef = useRef(selectedElementIds); selectedElementIdsRef.current = selectedElementIds; // 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(); // Sync restored state to Yjs CRDT to prevent collab overwrite if (collab.status === 'connected') { for (const el of snap.elements) collab.setElement(el); for (const layer of snap.layers) collab.setLayer(layer); for (const block of snap.blocks) collab.setBlock(block); } setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rückgängig: letzte Aktion', type: 'info' }]); } }, [restoreSnapshot, syncHistory, collab]); const handleRedo = useCallback(() => { const snap = historyManagerRef.current.redo(); if (snap) { restoreSnapshot(snap); syncHistory(); // Sync restored state to Yjs CRDT to prevent collab overwrite if (collab.status === 'connected') { for (const el of snap.elements) collab.setElement(el); for (const layer of snap.layers) collab.setLayer(layer); for (const block of snap.blocks) collab.setBlock(block); } setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Wiederherstellen: letzte Aktion', type: 'info' }]); } }, [restoreSnapshot, syncHistory, collab]); 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); // Ensure activeLayerId matches a real layer if (data.layers.length > 0) { const hasActiveLayer = data.layers.some(l => l.id === 'layer-0'); if (!hasActiveLayer) { setActiveLayerId(data.layers[0].id); } } // Create default layer if backend has none if (data.layers.length === 0 && data.drawing) { const defaultLayer: CADLayer = { id: 'layer-0', name: 'Layer 0', visible: true, locked: false, color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null, }; createLayerTyped(token, data.drawing.id, defaultLayer).catch(() => {}); setLayers([defaultLayer]); setActiveLayerId('layer-0'); } setSavedStatus('gespeichert'); setDataLoaded(true); // Re-initialize HistoryManager with loaded project state so undo does not jump back to empty initial state historyManagerRef.current.initialize({ elements: data.elements, layers: data.layers.length > 0 ? data.layers : initialLayers, blocks: data.blocks, groups: [], bgConfig: null, }); syncHistory(); // Push initial data to Yjs CRDT after load // Issue #7: Only push to CRDT if backend data actually has content. // Pushing empty arrays would wipe existing CRDT data from other connected clients. const bgConfigs = bgConfig ? [bgConfig] : []; const hasContent = data.elements.length > 0 || data.layers.length > 0 || data.blocks.length > 0 || groups.length > 0 || bgConfigs.length > 0; if (collab.status === 'connected' && hasContent) { collab.loadFromState({ elements: data.elements, layers: data.layers, blocks: data.blocks, groups, bgConfigs, }); } } catch (err) { // Ignore race condition errors from React StrictMode double-render if (cancelled) return; if (err instanceof Error && err.message.includes('Superseded')) return; console.error('Failed to load project:', err); setSavedStatus('Fehler beim Laden'); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, token]); // Load unit settings from backend on mount React.useEffect(() => { if (!token) return; let cancelled = false; (async () => { try { const [unitSetting, gridSetting, scaleSetting] = await Promise.all([ getSetting(token, 'cad.unit').catch(() => null), getSetting(token, 'cad.gridSize').catch(() => null), getSetting(token, 'cad.scaleFactor').catch(() => null), ]); if (cancelled) return; if (unitSetting && (unitSetting.value === 'mm' || unitSetting.value === 'cm' || unitSetting.value === 'm')) { setUnit(unitSetting.value as UnitType); } if (gridSetting) { const gs = parseFloat(gridSetting.value); if (!isNaN(gs) && gs > 0) setGridSize(gs); } if (scaleSetting) { const sf = parseFloat(scaleSetting.value); if (!isNaN(sf) && sf > 0) setScaleFactor(sf); } } catch { // Settings not yet stored — use defaults } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [token]); // Save unit settings to backend when they change const handleUnitChange = useCallback((newUnit: UnitType) => { setUnit(newUnit); if (token) setSetting(token, 'cad.unit', newUnit).catch(() => {}); }, [token]); const handleGridSizeChange = useCallback((newSize: number) => { setGridSize(newSize); if (token) setSetting(token, 'cad.gridSize', String(newSize)).catch(() => {}); }, [token]); const handleScaleFactorChange = useCallback((newFactor: number) => { setScaleFactor(newFactor); if (token) setSetting(token, 'cad.scaleFactor', String(newFactor)).catch(() => {}); }, [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) // Merge: update local elements with remote version when properties differ, add remote-only elements const localIds = new Set(elements.map(e => e.id)); const remoteIds = new Set(collab.elements.map(e => e.id)); const remoteMap = new Map(collab.elements.map(e => [e.id, e])); let changed = false; const merged = elements.map(el => { const remote = remoteMap.get(el.id); if (remote && JSON.stringify(remote) !== JSON.stringify(el)) { changed = true; return remote; } return el; }); // Add remote-only elements collab.elements.forEach(el => { if (!localIds.has(el.id)) { changed = true; merged.push(el); } }); if (changed) { setElements(merged); } // Merge layers: local-first, update changed from remote, append remote-only if (collab.layers.length > 0) { const localLayerMap = new Map(layers.map(l => [l.id, l])); const remoteLayerMap = new Map(collab.layers.map(l => [l.id, l])); const mergedLayers = layers.map(ll => { const remote = remoteLayerMap.get(ll.id); return remote ? (JSON.stringify(remote) !== JSON.stringify(ll) ? remote : ll) : ll; }); collab.layers.forEach(rl => { if (!localLayerMap.has(rl.id)) mergedLayers.push(rl); }); if (mergedLayers.length !== layers.length || mergedLayers.some((ml, i) => ml !== layers[i])) { setLayers(mergedLayers); } // Ensure activeLayerId matches a real layer after collab merge const hasActiveInMerged = mergedLayers.some(l => l.id === activeLayerId); if (!hasActiveInMerged && mergedLayers.length > 0) { setActiveLayerId(mergedLayers[0].id); } } // Merge blocks: local-first, update changed from remote, append remote-only if (collab.blocks.length > 0) { const localBlockMap = new Map(blocks.map(b => [b.id, b])); const remoteBlockMap = new Map(collab.blocks.map(b => [b.id, b])); const mergedBlocks = blocks.map(lb => { const remote = remoteBlockMap.get(lb.id); return remote ? (JSON.stringify(remote) !== JSON.stringify(lb) ? remote : lb) : lb; }); collab.blocks.forEach(rb => { if (!localBlockMap.has(rb.id)) mergedBlocks.push(rb); }); if (mergedBlocks.length !== blocks.length || mergedBlocks.some((mb, i) => mb !== blocks[i])) { setBlocks(mergedBlocks); } } // Sync groups from remote → local if (collab.groups.length > 0) { const sameGroups = collab.groups.length === groups.length && collab.groups.every((g, i) => g.id === groups[i]?.id); if (!sameGroups) setGroups(collab.groups); } // Sync bgConfig from remote → local if (collab.bgConfigs.length > 0) { const remoteBg = collab.bgConfigs[0]; if (!bgConfig || remoteBg.src !== bgConfig.src || remoteBg.scale !== bgConfig.scale || remoteBg.offsetX !== bgConfig.offsetX || remoteBg.offsetY !== bgConfig.offsetY || remoteBg.visible !== bgConfig.visible || remoteBg.opacity !== bgConfig.opacity) { setBgConfig(remoteBg); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [collab.elements, collab.layers, collab.blocks, collab.groups, collab.bgConfigs, collab.status]); // Sync local → remote: push local groups to Yjs when groups change React.useEffect(() => { if (collab.status !== 'connected') return; for (const group of groups) { collab.setGroup(group); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [groups, collab.status]); // Sync local → remote: push local bgConfig to Yjs when bgConfig changes React.useEffect(() => { if (collab.status !== 'connected') return; if (bgConfig) { collab.setBgConfig(bgConfig.name || 'default', bgConfig); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [bgConfig, collab.status]); // Sync local → remote: push local layers to Yjs when layers change React.useEffect(() => { if (collab.status !== 'connected') return; for (const layer of layers) collab.setLayer(layer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [layers, collab.status]); // Sync local → remote: push local blocks to Yjs when blocks change React.useEffect(() => { if (collab.status !== 'connected') return; for (const block of blocks) collab.setBlock(block); // eslint-disable-next-line react-hooks/exhaustive-deps }, [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 handleToggleElementVisible = useCallback((id: string) => { const target = elements.find(e => e.id === id); if (!target) return; const updatedEl: CADElement = { ...target, properties: { ...target.properties, visible: target.properties?.visible === false ? true : false } }; setElements((prev) => prev.map((e) => e.id === id ? updatedEl : e)); // Call API outside the state updater if (token) { setSavedStatus('Speichert…'); updateElement(token, updatedEl.id, updatedEl).then(() => setSavedStatus('gespeichert')).catch((err) => { console.error('Failed to toggle element visibility:', err); setSavedStatus('Fehler beim Speichern'); }); } }, [elements, token]); 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'); }); } }, [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); }, []); // Inline text editor state const [editingTextElement, setEditingTextElement] = useState(null); const handleTextEdit = useCallback((el: CADElement) => { setEditingTextElement(el); }, []); const handleTextEditorSubmit = useCallback((text: string) => { if (editingTextElement && text.trim() !== '') { const updatedEl = { ...editingTextElement, properties: { ...editingTextElement.properties, text } }; setElements((prev) => prev.map((e) => e.id === editingTextElement.id ? updatedEl : e )); // Push to Yjs CRDT for real-time sync collab.setElement(updatedEl); // Update in backend if (token) { setSavedStatus('Speichert…'); updateElement(token, updatedEl.id, updatedEl).then(() => { setSavedStatus('gespeichert'); }).catch((err) => { console.error('Failed to save text edit:', err); setSavedStatus('Fehler beim Speichern'); }); } } setEditingTextElement(null); }, [editingTextElement, collab, token]); const handleTextEditorCancel = useCallback(() => { setEditingTextElement(null); }, []); const handleCommandTrigger = useCallback((msg: string) => { // Route group/ungroup actions from keyboard shortcuts if (msg === 'group') { const gm = groupManagerRef.current; const ids = selectedElementIdsRef.current; if (ids.length < 2) { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppieren: Mindestens 2 Elemente auswählen', type: 'info' }]); return; } const group = gm.createGroup(ids); setGroups(gm.getGroups()); collab.setGroup(group); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe erstellt: ${group.name} (${ids.length} Elemente)`, type: 'info' }]); return; } if (msg === 'ungroup') { const gm = groupManagerRef.current; const allGroups = gm.getGroups(); if (allGroups.length === 0) { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Degruppieren: Keine Gruppe vorhanden', type: 'info' }]); return; } const selIds = selectedElementIdsRef.current; let targetGroup = allGroups.find(g => g.elementIds.some(id => selIds.includes(id))); if (!targetGroup) targetGroup = allGroups[allGroups.length - 1]; gm.ungroup(targetGroup.id); setGroups(gm.getGroups()); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe aufgelöst: ${targetGroup.name}`, type: 'info' }]); return; } setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]); }, [collab]); // 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' }]); // Persist rename to backend if (token) { updateBlock(token, id, { name }).catch((err) => { console.error('Failed to save block rename:', err); }); } }, [token]); 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()}` })), }; // Persist duplicate to backend if (drawingId && token) { createBlockTyped(token, drawingId, copy).catch((err) => { console.error('Failed to save duplicated block:', err); }); } return [...prev, copy]; }); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block dupliziert', type: 'info' }]); }, [drawingId, token]); 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]); // Push to Yjs CRDT for real-time sync collab.setBlock(block); // Persist to backend if (drawingId && token) { setSavedStatus('Speichert…'); createBlockTyped(token, drawingId, block) .then(() => setSavedStatus('gespeichert')) .catch((err) => { console.error('Failed to save imported SVG block:', err); setSavedStatus('Fehler beim Speichern'); }); } setCommandHistory((prev) => [...prev, { prefix: '·', text: `SVG importiert: ${name}`, type: 'info' }]); }, [collab, drawingId, token]); const handleSaveGroupAsBlock = useCallback((name: string) => { const selectedIds = selectedElementIdsRef.current; const selectedEls = selectedIds.length > 0 ? elementsRef.current.filter(e => selectedIds.includes(e.id)) : (selectedElement ? [selectedElement] : []); if (selectedEls.length === 0) { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Keine Elemente ausgewählt', type: 'info' }]); return; } 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)}` })), }; // Push to Yjs CRDT for real-time sync collab.setBlock(block); // Persist to backend if (drawingId && token) { createBlockTyped(token, drawingId, block).catch((err) => { console.error('Failed to save group block:', err); }); } return [...prev, block]; }); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block erstellt: ${name} (${selectedEls.length} Elemente)`, type: 'info' }]); }, [selectedElement, collab, drawingId, token]); 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) => { // BlockLibrary handles search filtering locally — this callback is for // any App-level side effects of block search (e.g. logging, analytics). if (query.trim()) { setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block-Suche: ${query}`, type: 'info' }]); } }, []); 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' }]); // Push to Yjs CRDT for real-time sync if (collab.status === 'connected') { collab.setElement(instance); } // Save to backend if (drawingId && token) { setSavedStatus('Speichert…'); createElementTyped(token, drawingId, instance).then(() => { setSavedStatus('gespeichert'); }).catch((err) => { console.error('Failed to save block instance:', err); setSavedStatus('Fehler beim Speichern'); }); } } }, [blocks, activeLayerId, collab, drawingId, token]); const handleSelectionChange = useCallback((selectedIds: string[]) => { setSelectedElementIds(selectedIds); if (selectedIds.length === 1) { const el = elements.find(e => e.id === selectedIds[0]); setSelectedElement(el ?? null); } else if (selectedIds.length === 0) { setSelectedElement(null); } else { // Multiple elements selected — keep the first one as a representative for the properties panel const el = elements.find(e => e.id === selectedIds[0]); setSelectedElement(el ?? 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]); // Push imported elements to Yjs and backend for (const el of result.elements) { collab.setElement(el); } if (drawingId && token) { setSavedStatus('Speichert…'); Promise.all(result.elements.map(el => createElementTyped(token, drawingId, el))) .then(() => setSavedStatus('gespeichert')) .catch((err) => { console.error('Failed to save imported elements:', err); setSavedStatus('Fehler beim Speichern'); }); } if (result.layers && result.layers.length > 0) { const existingIds = new Set(layers.map(l => l.id)); const newLayers = result.layers.filter(l => !existingIds.has(l.id)); setLayers((prev) => [...prev, ...newLayers]); // Push imported layers to Yjs and backend for (const layer of newLayers) { collab.setLayer(layer); } if (drawingId && token) { Promise.all(newLayers.map(l => createLayerTyped(token, drawingId, l))) .catch((err) => console.error('Failed to save imported layers:', err)); } } if (result.blocks && result.blocks.length > 0) { setBlocks((prev) => [...prev, ...result.blocks!]); // Push imported blocks to Yjs and backend for (const block of result.blocks) { collab.setBlock(block); } if (drawingId && token) { Promise.all(result.blocks.map(b => createBlockTyped(token, drawingId, b))) .catch((err) => console.error('Failed to save imported blocks:', err)); } } 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' }]); } }, [drawingId, token, collab, layers]); 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) => { 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') { setExportFormatOpen(true); return; } // ─── Edit actions ─── if (action === 'undo') { handleUndo(); return; } if (action === 'redo') { handleRedo(); return; } if (action === 'copy') { const ids = selectedElementIdsRef.current; const selected = ids.length > 0 ? elements.filter(e => ids.includes(e.id)) : []; 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.x ?? 0) + offset, y: (el.y ?? 0) + offset, })); setElements((prev) => [...prev, ...pasted]); setCommandHistory((prev) => [...prev, { prefix: '·', text: `${pasted.length} Element(e) eingefügt`, type: 'info' }]); // Push pasted elements to Yjs CRDT and backend for (const el of pasted) { collab.setElement(el); } if (drawingId && token) { setSavedStatus('Speichert…'); Promise.all(pasted.map(el => createElementTyped(token, drawingId, el))).then(() => { setSavedStatus('gespeichert'); }).catch((err) => { console.error('Failed to save pasted elements:', err); setSavedStatus('Fehler beim Speichern'); }); } } else { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zwischenablage leer', type: 'info' }]); } return; } // ─── Group/Ungroup actions ─── if (action === 'group') { const gm = groupManagerRef.current; const ids = selectedElementIdsRef.current; if (ids.length < 2) { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppieren: Mindestens 2 Elemente auswählen', type: 'info' }]); return; } const group = gm.createGroup(ids); const newGroups = gm.getGroups(); setGroups(newGroups); collab.setGroup(group); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe erstellt: ${group.name} (${ids.length} Elemente)`, type: 'info' }]); return; } if (action === 'ungroup') { const gm = groupManagerRef.current; const allGroups = gm.getGroups(); if (allGroups.length === 0) { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Degruppieren: Keine Gruppe vorhanden', type: 'info' }]); return; } // Find the group that contains any selected element const selIds = selectedElementIdsRef.current; let targetGroup = allGroups.find(g => g.elementIds.some(id => selIds.includes(id))); if (!targetGroup) targetGroup = allGroups[allGroups.length - 1]; gm.ungroup(targetGroup.id); setGroups(gm.getGroups()); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe aufgelöst: ${targetGroup.name}`, 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: ImageElement = { id: `el-${Date.now()}`, type: 'image', layerId: activeLayerId, x: 0, y: 0, width: 200, height: 200, properties: { src: reader.result as string }, }; setElements((prev) => [...prev, imgEl]); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Bild eingefügt', type: 'info' }]); // Push image element to Yjs CRDT for real-time sync collab.setElement(imgEl); // Save to backend if (drawingId && token) { setSavedStatus('Speichert…'); createElementTyped(token, drawingId, imgEl).then(() => { setSavedStatus('gespeichert'); }).catch((err) => { console.error('Failed to save image element:', err); setSavedStatus('Fehler beim Speichern'); }); } }; reader.readAsDataURL(input.files[0]); } }; input.click(); return; } // ─── Format actions ─── if (action.startsWith('format-')) { const formatType = action.replace('format-', ''); const selectedIds = selectedElementIdsRef.current; const selected = selectedIds.length > 0 ? elements.filter(e => selectedIds.includes(e.id)) : (selectedElement ? [selectedElement] : []); if (selected.length === 0) { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Format: Keine Elemente ausgewählt', type: 'info' }]); return; } const modified = selected.map(el => { const props = { ...el.properties }; switch (formatType) { case 'stroke-width': props.strokeWidth = (props.strokeWidth ?? 1) + 0.5; break; case 'stroke-color': props.stroke = props.stroke === '#ff0000' ? '#00ff00' : '#ff0000'; break; case 'fill-color': props.fill = props.fill === '#ff0000' ? '#00ff00' : '#ff0000'; break; case 'stroke-style': props.lineType = props.lineType === 'solid' ? 'dashed' : 'solid'; break; case 'align-left': return { ...el, x: selected.reduce((min, s) => Math.min(min, s.x), Infinity) }; case 'align-center': const cx = selected.reduce((sum, s) => sum + s.x, 0) / selected.length; return { ...el, x: cx }; case 'align-right': return { ...el, x: selected.reduce((max, s) => Math.max(max, s.x), -Infinity) }; default: return el; } return { ...el, properties: props }; }); handleElementsModified(modified); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Format: ${formatType} auf ${modified.length} Element(e) angewendet`, type: 'info' }]); return; } // ─── View actions ─── if (action === 'zoom-fit') { setZoomCommand('fit'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]); return; } if (action === 'zoom-100') { setZoomCommand('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 === '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, handleElementsModified, handleImport, handleExport, onNavigateBack, drawingId, token, elements, activeLayerId, gridEnabled, collab, selectedElement]); 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 handleSelectElement = useCallback((id: string) => { setSelectedElementIds([id]); const el = elements.find(e => e.id === id); setSelectedElement(el ?? null); }, [elements]); 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) => { const updated = prev.map((l) => l.id === id ? { ...l, visible: !l.visible } : l); const changed = updated.find((l) => l.id === id); if (changed) { collab.setLayer(changed); if (token) { updateLayer(token, id, { visible: changed.visible }).catch((err) => { console.error('Failed to save layer visibility:', err); }); } } return updated; }); }, [token, collab]); 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) => { const updated = prev.map((l) => l.id === id ? { ...l, name } : l); const changed = updated.find((l) => l.id === id); if (changed) { collab.setLayer(changed); if (token) { updateLayer(token, id, { name }).catch((err) => { console.error('Failed to save layer rename:', err); }); } } return updated; }); }, [token, collab]); 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 }; collab.setLayer(copy); if (drawingId && token) { createLayerTyped(token, drawingId, copy).catch((err) => { console.error('Failed to save duplicated layer:', err); }); } return [...prev, copy]; }); }, [drawingId, token, collab]); const handleToggleLock = useCallback((id: string) => { setLayers((prev) => { const updated = prev.map((l) => l.id === id ? { ...l, locked: !l.locked } : l); const changed = updated.find((l) => l.id === id); if (changed) { collab.setLayer(changed); if (token) { updateLayer(token, id, { locked: changed.locked }).catch((err) => { console.error('Failed to save layer lock state:', err); }); } } return updated; }); }, [token, collab]); const handleUpdateLayerColor = useCallback((id: string, color: string) => { setLayers((prev) => { const updated = prev.map((l) => l.id === id ? { ...l, color } : l); const changed = updated.find((l) => l.id === id); if (changed) { collab.setLayer(changed); if (token) { updateLayer(token, id, { color }).catch((err) => { console.error('Failed to save layer color:', err); }); } } return updated; }); }, [token, collab]); const handleUpdateLayerLineType = useCallback((id: string, lineType: 'solid' | 'dashed' | 'dotted') => { setLayers((prev) => { const updated = prev.map((l) => l.id === id ? { ...l, lineType } : l); const changed = updated.find((l) => l.id === id); if (changed) { collab.setLayer(changed); if (token) { updateLayer(token, id, { lineType }).catch((err) => { console.error('Failed to save layer line type:', err); }); } } return updated; }); }, [token, collab]); const handleUpdateLayerTransparency = useCallback((id: string, transparency: number) => { setLayers((prev) => { const updated = prev.map((l) => l.id === id ? { ...l, transparency } : l); const changed = updated.find((l) => l.id === id); if (changed) { collab.setLayer(changed); if (token) { updateLayer(token, id, { transparency }).catch((err) => { console.error('Failed to save layer transparency:', err); }); } } return updated; }); }, [token, collab]); const handleZoomIn = useCallback(() => { setZoomCommand('in'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: vergrößert', type: 'info' }]); }, []); const handleZoomOut = useCallback(() => { setZoomCommand('out'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: verkleinert', type: 'info' }]); }, []); const handleZoomFit = useCallback(() => { setZoomCommand('fit'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]); }, []); // Layer reorder (drag & drop) const handleReorderLayer = useCallback((draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => { setLayers((prev) => { const dragged = prev.find((l) => l.id === draggedId); if (!dragged) return prev; if (position === 'inside') { // Make dragged a child of target const updated = prev.map((l) => l.id === draggedId ? { ...l, parentId: targetId } : l ); return updated; } // before/after: reorder at same level const target = prev.find((l) => l.id === targetId); if (!target) return prev; const parentId = target.parentId; const siblings = prev .filter((l) => l.parentId === parentId && l.id !== draggedId) .sort((a, b) => a.sortOrder - b.sortOrder); const targetIdx = siblings.findIndex((l) => l.id === targetId); const insertIdx = position === 'before' ? targetIdx : targetIdx + 1; siblings.splice(insertIdx, 0, { ...dragged, parentId }); // Reassign sortOrder const reordered = siblings.map((l, i) => ({ ...l, sortOrder: i })); // Merge back: non-siblings keep original, siblings get reordered const nonSiblings = prev.filter((l) => l.parentId !== parentId || l.id === draggedId); const result = [...nonSiblings.filter((l) => l.id !== draggedId), ...reordered]; return result; }); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Ebene verschoben', type: 'info' }]); }, []); // Add sub-layer const handleAddSubLayer = useCallback((parentId: string) => { setLayers((prev) => { const parent = prev.find((l) => l.id === parentId); if (!parent) return prev; const newId = `layer-${Date.now()}`; const siblings = prev.filter((l) => l.parentId === parentId); const newLayer: CADLayer = { id: newId, name: `Teilebene ${siblings.length + 1}`, visible: true, locked: false, color: parent.color, lineType: 'solid', transparency: 0, sortOrder: siblings.length, parentId, }; if (drawingId && token) { createLayerTyped(token, drawingId, newLayer).catch((err) => { console.error('Failed to save sub-layer:', err); }); } return [...prev, newLayer]; }); }, [drawingId, token]); // Update single element (from properties panel) const handleUpdateElement = useCallback((updated: CADElement) => { setElements((prev) => { const newElements = prev.map((e) => (e.id === updated.id ? updated : e)); historyManagerRef.current.pushSnapshot({ elements: newElements, layers, blocks, groups, bgConfig, }, 'Element geändert'); return newElements; }); syncHistory(); collab.setElement(updated); if (token) { setSavedStatus('Speichert…'); updateElement(token, updated.id, updated).then(() => { setSavedStatus('gespeichert'); }).catch((err) => { console.error('Failed to update element:', err); setSavedStatus('Fehler beim Speichern'); }); } setSelectedElement(updated); }, [layers, blocks, groups, bgConfig, syncHistory, token, collab]); 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; const ids = selectedElementIdsRef.current; if (ids.length < 2) { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe: Mindestens 2 Elemente auswählen', type: 'info' }]); return; } const group = gm.createGroup(ids); const newGroups = gm.getGroups(); setGroups(newGroups); // Sync group to Yjs CRDT for real-time collaboration collab.setGroup(group); setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe erstellt: ${group.name} (${ids.length} Elemente)`, type: 'info' }]); 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) { pluginCmd.execute(parts.slice(1), createPluginContext()); } else { setCommandHistory((prev) => [...prev, { prefix: '·', text: `Unbekannter Befehl: ${cmd}`, type: 'info' }]); } } }, [handleUndo, handleRedo, handleRibbonAction, collab, createPluginContext]); 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 (
setSettingsOpen(true)} onOpenLeftDrawer={() => setMobileLeftOpen(true)} onOpenRightDrawer={() => setMobileRightOpen(true)} unit={unit} onUnitChange={handleUnitChange} onNavigateBack={onNavigateBack} /> setRulerEnabled((p) => !p)} />
{ setMobileLeftOpen(false); setLeftSidebarCollapsed(true); }} collapsed={leftSidebarCollapsed} onToggleCollapse={() => setLeftSidebarCollapsed((p) => !p)} blocks={blocks} onDragBlock={handleDragBlock} onBlockCategoryChange={handleBlockCategoryChange} onBlockSearch={handleBlockSearch} onRenameBlock={handleRenameBlock} onDuplicateBlock={handleDuplicateBlock} onDeleteBlock={handleDeleteBlock} onSvgImport={handleSvgImport} onSaveGroupAsBlock={handleSaveGroupAsBlock} onGroup={() => handleRibbonAction('group')} onUngroup={() => handleRibbonAction('ungroup')} leftbarWidth={leftbarWidth} onWidthChange={setLeftbarWidth} /> { setMobileRightOpen(false); setRightSidebarCollapsed(true); }} collapsed={rightSidebarCollapsed} onToggleCollapse={() => setRightSidebarCollapsed((p) => !p)} rightbarWidth={rightbarWidth} onWidthChange={setRightbarWidth} onReorder={handleReorderLayer} onAddSubLayer={handleAddSubLayer} onUpdateElement={handleUpdateElement} selectedElementIds={selectedElementIds} groups={groups} onSelectElement={handleSelectElement} token={token} unit={unit} scaleFactor={scaleFactor} />
setMobileLeftOpen(false)} onCloseRight={() => setMobileRightOpen(false)} onRightTabChange={setActiveDrawerTab} /> setSettingsOpen(false)} unit={unit} gridSize={gridSize} scaleFactor={scaleFactor} onUnitChange={handleUnitChange} onGridSizeChange={handleGridSizeChange} onScaleFactorChange={handleScaleFactorChange} /> 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)} /> )} {exportFormatOpen && (
setExportFormatOpen(false)}>
e.stopPropagation()}>

Export als…

{(['dxf', 'svg', 'pdf', 'png', 'json'] as ExportFormat[]).map(fmt => ( ))}
)} {editingTextElement && ( )}
); }; // ─── 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;