feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -0,0 +1,942 @@
|
||||
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 ──────────────────────────────────────────────
|
||||
const initialLayers: CADLayer[] = [
|
||||
{ id: 'layer-0', name: 'Wände', visible: true, locked: false, color: '#e74c3c', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null },
|
||||
{ id: 'layer-1', name: 'Türen', visible: true, locked: false, color: '#3498db', lineType: 'solid', transparency: 0, sortOrder: 1, parentId: null },
|
||||
{ id: 'layer-2', name: 'Bestuhlung', visible: true, locked: false, color: '#2ecc71', lineType: 'solid', transparency: 0, sortOrder: 2, parentId: null },
|
||||
{ id: 'layer-3', name: 'Bühne', visible: true, locked: false, color: '#f39c12', lineType: 'solid', transparency: 0, sortOrder: 3, parentId: null },
|
||||
{ id: 'layer-4', name: 'Hintergrund', visible: true, locked: true, color: '#95a5a6', lineType: 'dotted', transparency: 50, sortOrder: 4, parentId: null },
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const CADEditor: React.FC<CADEditorProps> = ({ projectId, token }) => {
|
||||
// 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>('layer-0');
|
||||
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[]>([]);
|
||||
|
||||
// ─── 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) {
|
||||
for (const layer of initialLayers) {
|
||||
createLayerTyped(token, data.drawing.id, layer).catch(() => {});
|
||||
}
|
||||
}
|
||||
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
|
||||
const sameElements = collab.elements.length === elements.length &&
|
||||
collab.elements.every((el, i) => el.id === elements[i]?.id);
|
||||
if (!sameElements) setElements(collab.elements);
|
||||
|
||||
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 newElements = [...elements, el];
|
||||
historyManagerRef.current.pushSnapshot({
|
||||
elements: newElements, layers, blocks, groups, bgConfig,
|
||||
}, 'Element erstellt');
|
||||
setElements(newElements);
|
||||
syncHistory();
|
||||
// Push to Yjs CRDT for real-time sync
|
||||
collab.setElement(el);
|
||||
// Save to backend
|
||||
if (drawingId && token) {
|
||||
setSavedStatus('Speichert…');
|
||||
createElementTyped(token, drawingId, el).then(() => {
|
||||
setSavedStatus('gespeichert');
|
||||
}).catch((err) => {
|
||||
console.error('Failed to save element:', err);
|
||||
setSavedStatus('Fehler beim Speichern');
|
||||
});
|
||||
}
|
||||
}, [elements, layers, blocks, groups, bgConfig, syncHistory, drawingId, token, collab]);
|
||||
|
||||
const handleElementsDeleted = useCallback((ids: string[]) => {
|
||||
const newElements = elements.filter((e) => !ids.includes(e.id));
|
||||
historyManagerRef.current.pushSnapshot({
|
||||
elements: newElements, layers, blocks, groups, bgConfig,
|
||||
}, `${ids.length} Element(e) gelöscht`);
|
||||
setElements(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[]) => {
|
||||
const newElements = elements.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`);
|
||||
setElements(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');
|
||||
});
|
||||
}
|
||||
}, [elements, 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' }]);
|
||||
if (action === 'background-import') {
|
||||
setBgImportOpen(true);
|
||||
}
|
||||
if (action === 'history') {
|
||||
setHistoryPanelOpen((prev) => !prev);
|
||||
}
|
||||
if (action === 'undo') {
|
||||
handleUndo();
|
||||
}
|
||||
if (action === 'redo') {
|
||||
handleRedo();
|
||||
}
|
||||
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();
|
||||
}
|
||||
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 {
|
||||
// Default to DXF if no valid extension
|
||||
handleExport('dxf');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
}, [handleUndo, handleRedo, handleImport, handleExport]);
|
||||
|
||||
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 === 'layer-0')?.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} />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user