Files
web-cad/frontend/src/App.tsx
T
Leopoldadmin 39422a4348 fix: layer IDs globally unique - use timestamp-based IDs instead of hardcoded layer-0/1/2/3/4
Root cause: layers.id is TEXT PRIMARY KEY (global unique across all drawings).
Hardcoded IDs layer-0..layer-4 collided when multiple projects tried to create
the same layer IDs. Second project got UNIQUE constraint failed on POST /layers.
Without layers, elements had dangling layer_id refs causing FOREIGN KEY errors.

Fix: makeInitialLayers() generates unique IDs: layer-{timestamp}-{index}.
activeLayerId initialized from first layer, not hardcoded.
activeLayerName uses activeLayerId variable, not hardcoded layer-0.
On project load with existing layers, activeLayerId set from first DB layer.
2026-06-26 20:53:41 +02:00

1087 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>();
// ─── CAD Editor Component ───────────────────────────────
interface CADEditorProps {
projectId: string;
token: string;
onNavigateBack: () => void;
}
const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack }) => {
// Theme
const [theme, setTheme] = useState<Theme>('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<string | null>(null);
// Ribbon
const [activeRibbonTab, setActiveRibbonTab] = useState<RibbonTab>('start');
// Tools
const [activeTool, setActiveTool] = useState('select');
// Canvas / View
const [viewMode, setViewMode] = useState<ViewMode>('2d');
const [cursorPos, setCursorPos] = useState<CursorPos>({ 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<RightPanel>('tool');
const [selectedElement, setSelectedElement] = useState<CADElement | null>(null);
const [layers, setLayers] = useState<CADLayer[]>(initialLayers);
const [activeLayerId, setActiveLayerId] = useState<string>(initialLayers[0].id);
const [blocks, setBlocks] = useState<BlockDefinition[]>(initialBlocks);
const [blockCategory, setBlockCategory] = useState('Alle');
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
// Command line
const [commandHistory, setCommandHistory] = useState<CommandHistoryEntry[]>(initialCommandHistory);
// KI Copilot
const [kiMessages, setKIMessages] = useState<KIMessage[]>(initialKIMessages);
const [kiSuggestions] = useState<KISuggestion[]>(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<DrawerTab>('tool');
// Background
const [bgImportOpen, setBgImportOpen] = useState(false);
const [bgConfig, setBgConfig] = useState<BackgroundConfig | null>(null);
const bgServiceRef = React.useRef<BackgroundService>(new BackgroundService());
// History panel
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
// Elements + undo/redo (HistoryManager)
const [elements, setElements] = useState<CADElement[]>([]);
const historyManagerRef = React.useRef<HistoryManager>(new HistoryManager());
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
const [toolState, setToolState] = useState<ToolState | null>(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<ElementGroup[]>([]);
// Clipboard (for copy/paste)
const clipboardRef = React.useRef<CADElement[] | null>(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<string, number> = {};
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 (
<div className={`app ${theme}`} data-theme={theme}>
<Topbar
projectName={projectName}
savedStatus={savedStatus}
onUndo={handleUndo}
onRedo={handleRedo}
onThemeToggle={handleThemeToggle}
theme={theme}
/>
<RibbonBar
activeTab={activeRibbonTab}
onTabChange={setActiveRibbonTab}
onAction={handleRibbonAction}
/>
<div className="app-body">
<LeftSidebar
activeTool={activeTool}
onToolChange={handleToolChange}
selectedTemplate={selectedTemplate}
onTemplateSelect={handleTemplateSelect}
/>
<CanvasArea
cursorPos={cursorPos}
viewMode={viewMode}
onViewChange={handleViewChange}
gridEnabled={gridEnabled}
orthoEnabled={orthoEnabled}
snapEnabled={snapEnabled}
polarEnabled={polarEnabled}
activeTool={activeTool}
elements={elements}
layers={layers}
onElementCreated={handleElementCreated}
onElementsDeleted={handleElementsDeleted}
onElementsModified={handleElementsModified}
onCursorMoved={handleCursorMoved}
onToolStateChanged={handleToolStateChanged}
onToggleGrid={handleToggleGrid}
onToggleOrtho={handleToggleOrtho}
onToggleSnap={handleToggleSnap}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onZoomFit={handleZoomFit}
onTextEdit={handleTextEdit}
onCommandTrigger={handleCommandTrigger}
blocks={blocks}
onBlockDrop={handleBlockDrop}
onSelectionChange={handleSelectionChange}
selectedTemplate={selectedTemplate}
bgConfig={bgConfig}
remoteCursors={collab.cursors}
/>
<RightSidebar
activePanel={activeRightPanel}
onPanelChange={setActiveRightPanel}
selectedElement={selectedElement}
layers={layers}
blocks={blocks}
activeLayerId={activeLayerId}
onSelectLayer={handleSelectLayer}
onAddLayer={handleAddLayer}
onToggleLayer={handleToggleLayer}
onDeleteLayer={handleDeleteLayer}
onRenameLayer={handleRenameLayer}
onDuplicateLayer={handleDuplicateLayer}
onToggleLock={handleToggleLock}
onUpdateLayerColor={handleUpdateLayerColor}
onUpdateLayerLineType={handleUpdateLayerLineType}
onUpdateLayerTransparency={handleUpdateLayerTransparency}
onRenameBlock={handleRenameBlock}
onDuplicateBlock={handleDuplicateBlock}
onDeleteBlock={handleDeleteBlock}
onSvgImport={handleSvgImport}
onSaveGroupAsBlock={handleSaveGroupAsBlock}
onBlockCategoryChange={handleBlockCategoryChange}
onBlockSearch={handleBlockSearch}
onDragBlock={handleDragBlock}
kiMessages={kiMessages}
kiSuggestions={kiSuggestions}
onKISend={handleKISend}
onKISuggestionClick={handleSuggestionClick}
kiLoading={kiLoading}
/>
</div>
<CommandLine
history={commandHistory}
onCommand={handleCommand}
/>
<StatusBar
snapEnabled={snapEnabled}
orthoEnabled={orthoEnabled}
polarEnabled={polarEnabled}
gridEnabled={gridEnabled}
cursorX={cursorPos.x}
cursorY={cursorPos.y}
activeLayer={activeLayerName}
activeTool={activeTool}
onlineCount={onlineCount}
onToggleSnap={handleToggleSnap}
onToggleOrtho={handleToggleOrtho}
onTogglePolar={handleTogglePolar}
onToggleGrid={handleToggleGrid}
seatCount={seatCount}
/>
<MobileDrawers
leftOpen={mobileLeftOpen}
rightOpen={mobileRightOpen}
activeRightTab={activeDrawerTab}
onCloseLeft={() => setMobileLeftOpen(false)}
onCloseRight={() => setMobileRightOpen(false)}
onRightTabChange={setActiveDrawerTab}
/>
<BackgroundImport
open={bgImportOpen}
onClose={() => setBgImportOpen(false)}
onApply={handleBgApply}
backgroundService={bgServiceRef.current}
/>
{historyPanelOpen && (
<HistoryPanel
entries={historyEntries}
onJumpTo={(entryId: string) => {
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)}
/>
)}
</div>
);
};
// ─── App Wrapper (Auth Gate) ───────────────────────────
const App: React.FC = () => {
const { user, token } = useAuth();
const [authView, setAuthView] = useState<'login' | 'register'>('login');
const [openedProjectId, setOpenedProjectId] = useState<string | null>(null);
// Not authenticated → show Login or Register
if (!user || !token) {
return authView === 'login'
? <Login onSwitchToRegister={() => setAuthView('register')} />
: <Register onSwitchToLogin={() => setAuthView('login')} />;
}
// Authenticated but no project opened → show Dashboard
if (!openedProjectId) {
return <Dashboard onOpenProject={(id) => setOpenedProjectId(id)} />;
}
// Authenticated + project opened → show CAD Editor
return <CADEditor projectId={openedProjectId} token={token} onNavigateBack={() => setOpenedProjectId(null)} />;
};
export default App;