From affa6be151e0b40b6f3dafaddcfb46cbf777f66c Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Sat, 27 Jun 2026 14:27:34 +0200 Subject: [PATCH] feat: layer panel with element tree - shows layers + elements as children with count badges, sub-layers supported --- frontend/src/App.tsx | 1 + frontend/src/App.tsx.bak | 1076 ++++++++++++++++++++++ frontend/src/components/LayerPanel.tsx | 329 +++---- frontend/src/components/RightSidebar.tsx | 3 +- frontend/src/types/ui.types.ts | 2 + 5 files changed, 1228 insertions(+), 183 deletions(-) create mode 100644 frontend/src/App.tsx.bak diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e592c0..ac09f0f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -980,6 +980,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack onRenameLayer={handleRenameLayer} onDuplicateLayer={handleDuplicateLayer} onToggleLock={handleToggleLock} + elements={elements} onRenameBlock={handleRenameBlock} onDuplicateBlock={handleDuplicateBlock} onDeleteBlock={handleDeleteBlock} diff --git a/frontend/src/App.tsx.bak b/frontend/src/App.tsx.bak new file mode 100644 index 0000000..47c55bf --- /dev/null +++ b/frontend/src/App.tsx.bak @@ -0,0 +1,1076 @@ +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(); + +// ─── CAD Editor Component ─────────────────────────────── +interface CADEditorProps { + projectId: string; + token: string; + onNavigateBack: () => void; +} + +const CADEditor: React.FC = ({ projectId, token, onNavigateBack }) => { + // Theme + const [theme, setTheme] = useState('dark'); + + // Auth user for collaboration + const { user } = useAuth(); + + // Yjs collaboration binding + const collab = useYjsBinding({ + docName: `project-${projectId}`, + userId: user?.id || 'anonymous', + userName: user?.name || 'Gast', + userColor: '#3498db', + enabled: true, + }); + + // Project + const [projectName, setProjectName] = useState('Unbenannt'); + const [savedStatus, setSavedStatus] = useState('Lädt…'); + const [drawingId, setDrawingId] = useState(null); + + // Ribbon + const [activeRibbonTab, setActiveRibbonTab] = useState('start'); + + // Tools + const [activeTool, setActiveTool] = useState('select'); + + // Canvas / View + const [viewMode, setViewMode] = useState('2d'); + const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); + const [gridEnabled, setGridEnabled] = useState(true); + const [orthoEnabled, setOrthoEnabled] = useState(false); + const [snapEnabled, setSnapEnabled] = useState(true); + const [polarEnabled, setPolarEnabled] = useState(false); + + // Right sidebar + const [activeRightPanel, setActiveRightPanel] = useState('tool'); + const [selectedElement, setSelectedElement] = useState(null); + const [layers, setLayers] = useState(initialLayers); + const [activeLayerId, setActiveLayerId] = useState('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 + useEffect(() => { + const ctx: PluginContext = { + addElement: (el) => setElements((prev) => [...prev, el]), + removeElement: (id) => setElements((prev) => prev.filter((e) => e.id !== id)), + updateElement: (id, props) => setElements((prev) => prev.map((e) => (e.id === id ? { ...e, ...props } : e))), + getElements: () => elements, + getLayers: () => layers, + getActiveLayerId: () => activeLayerId, + showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]), + log: (msg) => console.log(`[Plugin] ${msg}`), + }; + pluginRegistry.setContext(ctx); + registerBuiltinPlugins(); + pluginRegistry.initDefaults(); + }, []); + + // Status bar + const onlineCount = collab.cursors.length + 1; + + // Mobile drawers + const [mobileLeftOpen, setMobileLeftOpen] = useState(false); + const [mobileRightOpen, setMobileRightOpen] = useState(false); + const [activeDrawerTab, setActiveDrawerTab] = useState('tool'); + + // Background + const [bgImportOpen, setBgImportOpen] = useState(false); + const [bgConfig, setBgConfig] = useState(null); + const bgServiceRef = React.useRef(new BackgroundService()); + + // History panel + const [historyPanelOpen, setHistoryPanelOpen] = useState(false); + + // Elements + undo/redo (HistoryManager) + const [elements, setElements] = useState([]); + const historyManagerRef = React.useRef(new HistoryManager()); + const [historyEntries, setHistoryEntries] = useState([]); + const [toolState, setToolState] = useState(null); + + const seatCount = useMemo(() => { + const svc = new SeatingService(); + return svc.countSeats(elements).total; + }, [elements]); + + // Groups + const groupManagerRef = React.useRef(new GroupManager()); + const [groups, setGroups] = useState([]); + + // Clipboard (for copy/paste) + const clipboardRef = React.useRef(null); + + // ─── Handlers ─────────────────────────────────────────── + const handleThemeToggle = useCallback(() => { + setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); + }, []); + + const syncHistory = useCallback(() => { + setHistoryEntries(historyManagerRef.current.getHistory()); + }, []); + + const restoreSnapshot = useCallback((snap: CADStateSnapshot) => { + setElements(snap.elements); + setLayers(snap.layers); + setBlocks(snap.blocks); + setGroups(snap.groups); + setBgConfig(snap.bgConfig); + }, []); + + const handleUndo = useCallback(() => { + const snap = historyManagerRef.current.undo(); + if (snap) { + restoreSnapshot(snap); + syncHistory(); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rückgängig: letzte Aktion', type: 'info' }]); + } + }, [restoreSnapshot, syncHistory]); + + const handleRedo = useCallback(() => { + const snap = historyManagerRef.current.redo(); + if (snap) { + restoreSnapshot(snap); + syncHistory(); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Wiederherstellen: letzte Aktion', type: 'info' }]); + } + }, [restoreSnapshot, syncHistory]); + + const pushHistorySnapshot = useCallback((label: string) => { + historyManagerRef.current.pushSnapshot({ + elements, + layers, + blocks, + groups, + bgConfig, + }, label); + syncHistory(); + }, [elements, layers, blocks, groups, bgConfig, syncHistory]); + + // Initialize HistoryManager with initial state on mount + const historyInitRef = React.useRef(false); + React.useEffect(() => { + if (historyInitRef.current) return; + historyInitRef.current = true; + historyManagerRef.current.initialize({ + elements: [], + layers: initialLayers, + blocks: initialBlocks, + groups: [], + bgConfig: null, + }); + syncHistory(); + }, [syncHistory]); + + // Load project data from backend on mount + const [dataLoaded, setDataLoaded] = useState(false); + React.useEffect(() => { + if (!projectId || !token) return; + let cancelled = false; + (async () => { + try { + setSavedStatus('Lädt…'); + const data = await loadProjectDataTyped(token, projectId); + if (cancelled) return; + setProjectName(data.project.name); + setDrawingId(data.drawing?.id || null); + if (data.elements.length > 0) setElements(data.elements); + if (data.layers.length > 0) setLayers(data.layers); + if (data.blocks.length > 0) setBlocks(data.blocks); + // Save initial layers to backend if backend has none + if (data.layers.length === 0 && data.drawing) { + 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 AND local is not ahead of remote + // (prevents race condition where local element is overwritten by stale Yjs sync) + const localIds = new Set(elements.map(e => e.id)); + const remoteIds = new Set(collab.elements.map(e => e.id)); + const localHasExtra = elements.some(e => !remoteIds.has(e.id)); + const remoteHasExtra = collab.elements.some(e => !localIds.has(e.id)); + // Only sync from remote if remote has elements local doesn't have AND local doesn't have unsaved elements + if (remoteHasExtra && !localHasExtra) { + setElements(collab.elements); + } else if (remoteHasExtra && localHasExtra) { + // Merge: keep local elements, update any that changed remotely + const merged = elements.map(el => { + const remote = collab.elements.find(e => e.id === el.id); + return remote || el; + }); + // Add remote-only elements + collab.elements.forEach(el => { if (!localIds.has(el.id)) merged.push(el); }); + setElements(merged); + } + + const sameLayers = collab.layers.length === layers.length && + collab.layers.every((l, i) => l.id === layers[i]?.id); + if (!sameLayers) setLayers(collab.layers); + + const sameBlocks = collab.blocks.length === blocks.length && + collab.blocks.every((b, i) => b.id === blocks[i]?.id); + if (!sameBlocks) setBlocks(collab.blocks); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collab.elements, collab.layers, collab.blocks, collab.status]); + + const handleElementCreated = useCallback((el: CADElement) => { + const elWithLayer = el.layerId ? el : { ...el, layerId: activeLayerId }; + setElements((prev) => { + const newElements = [...prev, elWithLayer]; + historyManagerRef.current.pushSnapshot({ + elements: newElements, layers, blocks, groups, bgConfig, + }, 'Element erstellt'); + return newElements; + }); + syncHistory(); + // Push to Yjs CRDT for real-time sync + collab.setElement(elWithLayer); + // Save to backend + if (drawingId && token) { + setSavedStatus('Speichert…'); + createElementTyped(token, drawingId, elWithLayer).then(() => { + setSavedStatus('gespeichert'); + }).catch((err) => { + console.error('Failed to save element:', err); + setSavedStatus('Fehler beim Speichern'); + }); + } + }, [layers, blocks, groups, bgConfig, syncHistory, drawingId, token, collab, activeLayerId]); + + const handleElementsDeleted = useCallback((ids: string[]) => { + setElements((prev) => { + const newElements = prev.filter((e) => !ids.includes(e.id)); + historyManagerRef.current.pushSnapshot({ + elements: newElements, layers, blocks, groups, bgConfig, + }, `${ids.length} Element(e) gelöscht`); + return newElements; + }); + syncHistory(); + // Push deletions to Yjs CRDT for real-time sync + ids.forEach(id => collab.deleteElement(id)); + // Delete from backend + if (token) { + setSavedStatus('Speichert…'); + Promise.all(ids.map(id => apiDeleteElement(token, id))).then(() => { + setSavedStatus('gespeichert'); + }).catch((err) => { + console.error('Failed to delete elements:', err); + setSavedStatus('Fehler beim Speichern'); + }); + } + }, [elements, layers, blocks, groups, bgConfig, syncHistory, token, collab]); + + const handleElementsModified = useCallback((modified: CADElement[]) => { + setElements((prev) => { + const newElements = prev.map((e) => { + const found = modified.find((m) => m.id === e.id); + return found ?? e; + }); + historyManagerRef.current.pushSnapshot({ + elements: newElements, layers, blocks, groups, bgConfig, + }, `${modified.length} Element(e) geändert`); + return newElements; + }); + syncHistory(); + // Push modifications to Yjs CRDT for real-time sync + modified.forEach(el => collab.setElement(el)); + // Update in backend + if (token) { + setSavedStatus('Speichert…'); + Promise.all(modified.map(el => updateElement(token, el.id, el))).then(() => { + setSavedStatus('gespeichert'); + }).catch((err) => { + console.error('Failed to update elements:', err); + setSavedStatus('Fehler beim Speichern'); + }); + } + }, [layers, blocks, groups, bgConfig, syncHistory, token, collab]); + + const lastCursorUpdateRef = useRef(0); + const handleCursorMoved = useCallback((x: number, y: number) => { + const now = performance.now(); + if (now - lastCursorUpdateRef.current < 33) return; // throttle to ~30fps + lastCursorUpdateRef.current = now; + setCursorPos({ x, y }); + collab.setCursor(x, y); + }, [collab]); + + const handleToolStateChanged = useCallback((state: ToolState) => { + setToolState(state); + }, []); + + const handleTextEdit = useCallback((el: CADElement) => { + const text = window.prompt('Text eingeben:', ''); + if (text !== null && text.trim() !== '') { + setElements((prev) => prev.map((e) => + e.id === el.id ? { ...e, properties: { ...e.properties, text } } : e + )); + } + }, []); + + const handleCommandTrigger = useCallback((msg: string) => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]); + }, []); + + // Block management handlers + const handleRenameBlock = useCallback((id: string, name: string) => { + setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, name } : b)); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block umbenannt: ${name}`, type: 'info' }]); + }, []); + + const handleDuplicateBlock = useCallback((id: string) => { + setBlocks((prev) => { + const block = prev.find((b) => b.id === id); + if (!block) return prev; + const newId = `blk-${Date.now()}`; + const copy: BlockDefinition = { + ...block, + id: newId, + name: `${block.name} (Kopie)`, + elements: block.elements.map((el) => ({ ...el, id: `${el.id}_copy_${Date.now()}` })), + }; + return [...prev, copy]; + }); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block dupliziert', type: 'info' }]); + }, []); + + const handleDeleteBlock = useCallback((id: string) => { + setBlocks((prev) => prev.filter((b) => b.id !== id)); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block gelöscht', type: 'info' }]); + // Delete from backend + if (token) { + apiDeleteBlock(token, id).catch((err) => { + console.error('Failed to delete block:', err); + }); + } + }, [token]); + + const handleSvgImport = useCallback((svg: string, name: string, category: string) => { + const svc = new BlockService(); + const block = svc.importSVG(svg, name, category); + setBlocks((prev) => [...prev, block]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `SVG importiert: ${name}`, type: 'info' }]); + }, []); + + const handleSaveGroupAsBlock = useCallback((name: string) => { + const selectedEls = selectedElement ? [selectedElement] : []; + setBlocks((prev) => { + const newId = `blk_grp_${Date.now()}`; + const block: BlockDefinition = { + id: newId, + name, + description: 'Aus Auswahl erstellt', + category: 'Custom', + elements: selectedEls.map(el => ({ ...el, id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` })), + }; + return [...prev, block]; + }); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block erstellt: ${name} (${selectedEls.length} Elemente)`, type: 'info' }]); + }, [selectedElement]); + + const handleBlockCategoryChange = useCallback((cat: string) => { + setBlockCategory(cat); + }, []); + + const handleTemplateSelect = useCallback((templateName: string | null) => { + setSelectedTemplate(templateName); + if (templateName) { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Vorlage gewählt: ${templateName} – Klicken zum Platzieren`, type: 'info' }]); + } + }, []); + + const handleBlockSearch = useCallback((_query: string) => { + // Search is handled locally in BlockLibrary component + }, []); + + const handleDragBlock = useCallback((blockId: string) => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block gezogen: ${blockId}`, type: 'info' }]); + }, []); + + const handleBlockDrop = useCallback((blockId: string, x: number, y: number) => { + const svc = new BlockService(); + blocks.forEach(b => svc.addBlock(b)); + const instance = svc.createInstance(blockId, x, y, activeLayerId); + if (instance) { + setElements((prev) => [...prev, instance]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block platziert: ${blockId} bei (${x.toFixed(2)}, ${y.toFixed(2)})`, type: 'info' }]); + } + }, [blocks, activeLayerId]); + + const handleSelectionChange = useCallback((selectedIds: string[]) => { + if (selectedIds.length === 1) { + const el = elements.find(e => e.id === selectedIds[0]); + setSelectedElement(el ?? null); + } else { + setSelectedElement(null); + } + }, [elements]); + + const handleImport = useCallback(async (file: File) => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiere ${file.name}...`, type: 'info' }]); + const result = await importFile(file); + if (result.success) { + setElements((prev) => [...prev, ...result.elements]); + if (result.layers && result.layers.length > 0) { + setLayers((prev) => { + const existingIds = new Set(prev.map(l => l.id)); + const newLayers = result.layers!.filter(l => !existingIds.has(l.id)); + return [...prev, ...newLayers]; + }); + } + if (result.blocks && result.blocks.length > 0) { + setBlocks((prev) => [...prev, ...result.blocks!]); + } + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiert: ${result.elements.length} Elemente aus ${file.name}`, type: 'info' }]); + if (result.warnings.length > 0) { + result.warnings.forEach(w => setCommandHistory((prev) => [...prev, { prefix: '·', text: w, type: 'info' }])); + } + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Import fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]); + } + }, []); + + const handleExport = useCallback(async (format: ExportFormat) => { + const projectData: ProjectData = { + version: '1.0', + name: projectName, + layers, + elements, + blocks, + }; + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiere als ${format.toUpperCase()}...`, type: 'info' }]); + const result = await exportProject(projectData, { format }); + if (result.success && result.blob) { + downloadBlob(result.blob, result.filename); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiert: ${result.filename}`, type: 'info' }]); + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Export fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]); + } + }, [layers, elements, blocks]); + + const handleRibbonAction = useCallback((action: string) => { + setCommandHistory((prev) => [...prev, { prefix: '›', text: action, type: 'command' }]); + + // ─── File actions ─── + if (action === 'new') { + onNavigateBack(); + return; + } + if (action === 'open') { + onNavigateBack(); + return; + } + if (action === 'save') { + setSavedStatus('Gespeichert'); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Projekt gespeichert', type: 'info' }]); + // Save elements to backend if drawingId exists + if (drawingId && token) { + elements.forEach((el) => { + if (!el.id.startsWith('el-')) return; + updateElement(token, el.id, el).catch(() => {}); + }); + } + return; + } + if (action === 'import') { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.dxf,.svg,.json'; + input.onchange = () => { + if (input.files && input.files[0]) { + handleImport(input.files[0]); + } + }; + input.click(); + return; + } + if (action === 'export') { + const input = document.createElement('input'); + input.type = 'file'; + input.setAttribute('nwsave', ''); + input.accept = '.dxf,.svg,.pdf,.png,.json'; + input.onchange = () => { + const name = input.value || 'cad-export'; + const ext = name.split('.').pop()?.toLowerCase() as ExportFormat; + if (ext && ['dxf', 'svg', 'pdf', 'png', 'json'].includes(ext)) { + handleExport(ext); + } else { + handleExport('dxf'); + } + }; + input.click(); + return; + } + + // ─── Edit actions ─── + if (action === 'undo') { handleUndo(); return; } + if (action === 'redo') { handleRedo(); return; } + if (action === 'copy') { + const selected = elements.filter((e) => (e as any).selected); + const clip = selected.length > 0 ? selected : elements.slice(0, 1); + clipboardRef.current = clip; + setCommandHistory((prev) => [...prev, { prefix: '·', text: `${clip.length} Element(e) kopiert`, type: 'info' }]); + return; + } + if (action === 'paste') { + if (clipboardRef.current && clipboardRef.current.length > 0) { + const offset = 20; + const pasted = clipboardRef.current.map((el, i) => ({ + ...el, + id: `el-${Date.now()}-${i}`, + x: (el as any).x ? (el as any).x + offset : undefined, + y: (el as any).y ? (el as any).y + offset : undefined, + })); + setElements((prev) => [...prev, ...pasted]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `${pasted.length} Element(e) eingefügt`, type: 'info' }]); + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zwischenablage leer', type: 'info' }]); + } + return; + } + + // ─── Insert actions (set active tool) ─── + if (action === 'insert-line') { setActiveTool('line'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Linie-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-rect') { setActiveTool('rect'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rechteck-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-circle') { setActiveTool('circle'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Kreis-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-text') { setActiveTool('text'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Text-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-freehand') { setActiveTool('polyline'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Freihand-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-image') { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = () => { + if (input.files && input.files[0]) { + const reader = new FileReader(); + reader.onload = () => { + const imgEl: CADElement = { + id: `el-${Date.now()}`, + type: 'image' as any, + layerId: activeLayerId, + x: 0, y: 0, + properties: { src: reader.result as string, width: 200, height: 200 }, + } as any; + setElements((prev) => [...prev, imgEl]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Bild eingefügt', type: 'info' }]); + }; + reader.readAsDataURL(input.files[0]); + } + }; + input.click(); + return; + } + + // ─── Format actions ─── + if (action.startsWith('format-')) { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Format: ${action.replace('format-', '')} (Auswahl erforderlich)`, type: 'info' }]); + return; + } + + // ─── View actions ─── + if (action === 'zoom-fit') { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]); + return; + } + if (action === 'zoom-100') { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: 100%', type: 'info' }]); + return; + } + if (action === 'grid') { + setGridEnabled((p) => !p); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Grid ${gridEnabled ? 'aus' : 'ein'}`, type: 'info' }]); + return; + } + if (action === 'layer') { setActiveRightPanel('layer'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Layer-Manager geöffnet', type: 'info' }]); return; } + + // ─── Tools actions ─── + if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; } + if (action === 'search') { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Suche: Befehlszeile verwenden (Strg+F)', type: 'info' }]); + return; + } + if (action === 'plugins') { setActiveRightPanel('plugins'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Plugin-Manager geöffnet', type: 'info' }]); return; } + if (action === 'history') { setHistoryPanelOpen((prev) => !prev); return; } + + // ─── Background ─── + if (action === 'background-import') { setBgImportOpen(true); return; } + + // ─── KI actions ─── + if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; } + if (action === 'ki-draw') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Zeichnen: Beschreibung eingeben', type: 'info' }]); return; } + if (action === 'ki-analyze') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Analyse gestartet', type: 'info' }]); return; } + }, [handleUndo, handleRedo, handleImport, handleExport, onNavigateBack, drawingId, token, elements, activeLayerId, gridEnabled]); + + const handleToolChange = useCallback((tool: string) => { + setActiveTool(tool); + setMobileLeftOpen(false); + }, []); + + const handleViewChange = useCallback((mode: ViewMode) => { + setViewMode(mode); + }, []); + + const handleToggleGrid = useCallback(() => setGridEnabled((p) => !p), []); + const handleToggleOrtho = useCallback(() => setOrthoEnabled((p) => !p), []); + const handleToggleSnap = useCallback(() => setSnapEnabled((p) => !p), []); + const handleTogglePolar = useCallback(() => setPolarEnabled((p) => !p), []); + + // Layer handlers + const handleSelectLayer = useCallback((id: string) => setActiveLayerId(id), []); + const handleAddLayer = useCallback(() => { + setLayers((prev) => { + const newId = `layer-${Date.now()}`; + const newLayer: CADLayer = { + id: newId, name: `Layer ${prev.length + 1}`, visible: true, locked: false, + color: '#ffffff', lineType: 'solid', transparency: 0, + sortOrder: prev.length, parentId: null, + }; + // Save to backend + if (drawingId && token) { + createLayerTyped(token, drawingId, newLayer).catch((err) => { + console.error('Failed to save layer:', err); + }); + } + return [...prev, newLayer]; + }); + }, [drawingId, token]); + const handleToggleLayer = useCallback((id: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, visible: !l.visible } : l)); + }, []); + const handleDeleteLayer = useCallback((id: string) => { + setLayers((prev) => prev.filter((l) => l.id !== id)); + // Delete from backend + if (token) { + apiDeleteLayer(token, id).catch((err) => { + console.error('Failed to delete layer:', err); + }); + } + }, [token]); + const handleRenameLayer = useCallback((id: string, name: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, name } : l)); + }, []); + const handleDuplicateLayer = useCallback((id: string) => { + setLayers((prev) => { + const layer = prev.find((l) => l.id === id); + if (!layer) return prev; + const newId = `layer-${Date.now()}`; + const copy: CADLayer = { ...layer, id: newId, name: `${layer.name} (Kopie)`, sortOrder: prev.length }; + return [...prev, copy]; + }); + }, []); + const handleToggleLock = useCallback((id: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, locked: !l.locked } : l)); + }, []); + const handleUpdateLayerColor = useCallback((id: string, color: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, color } : l)); + }, []); + const handleUpdateLayerLineType = useCallback((id: string, lineType: 'solid' | 'dashed' | 'dotted') => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, lineType } : l)); + }, []); + const handleUpdateLayerTransparency = useCallback((id: string, transparency: number) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, transparency } : l)); + }, []); + + const handleZoomIn = useCallback(() => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: vergrößert', type: 'info' }]); + }, []); + const handleZoomOut = useCallback(() => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: verkleinert', type: 'info' }]); + }, []); + const handleZoomFit = useCallback(() => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]); + }, []); + + const handleBgApply = useCallback((config: BackgroundConfig, _image: HTMLImageElement | null) => { + setBgConfig(config); + setBgImportOpen(false); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Hintergrund geladen: ${config.name} (${config.width}×${config.height}px, Maßstab: ${config.scale.toFixed(3)} px/mm)`, type: 'info' }]); + }, []); + + const handleCommand = useCallback((cmd: string) => { + const upper = cmd.trim().toUpperCase(); + setCommandHistory((prev) => [...prev, { prefix: '›', text: cmd, type: 'command' }]); + + const registry = getCommandRegistry(); + + if (upper === 'UNDO' || upper === 'U') { + handleUndo(); + return; + } + if (upper === 'REDO') { + handleRedo(); + return; + } + + // Group command: create group from currently selected elements + if (upper === 'GROUP' || upper === 'GRP') { + const gm = groupManagerRef.current; + // For now, group all elements (selection state is in InteractionEngine, not accessible here) + // In a full implementation, we'd need selected IDs from the interaction engine + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe erstellt (Auswahl im Canvas erforderlich)', type: 'info' }]); + setGroups(gm.getGroups()); + return; + } + + // Ungroup command + if (upper === 'UNG' || upper === 'UNGROUP') { + const gm = groupManagerRef.current; + const allGroups = gm.getGroups(); + if (allGroups.length > 0) { + gm.ungroup(allGroups[allGroups.length - 1].id); + setGroups(gm.getGroups()); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe aufgelöst', type: 'info' }]); + } + return; + } + + // Import command — trigger file dialog + if (upper === 'IMPORT' || upper === 'IMP' || upper === 'I') { + handleRibbonAction('import'); + return; + } + // Export command — trigger export dialog + if (upper === 'EXPORT' || upper === 'EXP' || upper === 'EX') { + handleRibbonAction('export'); + return; + } + + const tool = registry.getToolId(upper); + if (tool) { + setActiveTool(tool); + setMobileLeftOpen(false); + const label = registry.getLabel(upper) ?? `Werkzeug: ${tool}`; + setCommandHistory((prev) => [...prev, { prefix: '·', text: label, type: 'info' }]); + } else { + // Check plugin commands + const pluginCmds = pluginRegistry.getCommandExtensions(); + const parts = cmd.trim().split(/\s+/); + const cmdName = parts[0].toUpperCase(); + const pluginCmd = pluginCmds.find((c) => c.name.toUpperCase() === cmdName); + if (pluginCmd) { + const ctx: PluginContext = { + addElement: (el) => setElements((prev) => [...prev, el]), + removeElement: (id) => setElements((prev) => prev.filter((e) => e.id !== id)), + updateElement: (id, props) => setElements((prev) => prev.map((e) => (e.id === id ? { ...e, ...props } : e))), + getElements: () => elements, + getLayers: () => layers, + getActiveLayerId: () => activeLayerId, + showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]), + log: (msg) => console.log(`[Plugin] ${msg}`), + }; + pluginCmd.execute(parts.slice(1), ctx); + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Unbekannter Befehl: ${cmd}`, type: 'info' }]); + } + } + }, [handleUndo, handleRedo, handleRibbonAction]); + + const handleKISend = useCallback(async (text: string) => { + const userMsg: KIMessage = { id: `ki-${Date.now()}`, role: 'user', content: text }; + const pendingId = `ki-${Date.now() + 1}`; + const pendingMsg: KIMessage = { id: pendingId, role: 'assistant', content: '…' }; + setKIMessages((prev) => [...prev, userMsg, pendingMsg]); + setKiLoading(true); + + try { + // Build CAD context + const elementTypeSummary: Record = {}; + for (const el of elements) { + elementTypeSummary[el.type] = (elementTypeSummary[el.type] || 0) + 1; + } + const context = { + projectName, + elementCount: elements.length, + layerCount: layers.length, + elementTypeSummary, + }; + + // Build message history (last 10 messages) + const history = kiMessages.slice(-10).map((m) => ({ + role: m.role, + content: typeof m.content === 'string' ? m.content : String(m.content), + })); + history.push({ role: 'user', content: text }); + + const result = await aiChat(token, history, context); + + setKIMessages((prev) => + prev.map((m) => + m.id === pendingId + ? { ...m, content: result.content } + : m + ) + ); + } catch (err: any) { + setKIMessages((prev) => + prev.map((m) => + m.id === pendingId + ? { ...m, content: `Fehler: ${err.message || 'KI-Anfrage fehlgeschlagen'}` } + : m + ) + ); + } finally { + setKiLoading(false); + } + }, [token, projectName, elements, layers, kiMessages]); + + const handleSuggestionClick = useCallback((suggestion: KISuggestion) => { + handleKISend(suggestion.label); + }, [handleKISend]); + + const activeLayerName = layers.find((l) => l.id === 'layer-0')?.name ?? '—'; + + // ─── Render ───────────────────────────────────────────── + return ( +
+ + +
+ + + +
+ + + setMobileLeftOpen(false)} + onCloseRight={() => setMobileRightOpen(false)} + onRightTabChange={setActiveDrawerTab} + /> + setBgImportOpen(false)} + onApply={handleBgApply} + backgroundService={bgServiceRef.current} + /> + {historyPanelOpen && ( + { + const snap = historyManagerRef.current.jumpTo(entryId); + if (snap) { + restoreSnapshot(snap); + syncHistory(); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Historie: zu „${snap.label}“ gesprungen`, type: 'info' }]); + } + }} + onClose={() => setHistoryPanelOpen(false)} + /> + )} +
+ ); +}; + +// ─── App Wrapper (Auth Gate) ─────────────────────────── +const App: React.FC = () => { + const { user, token } = useAuth(); + const [authView, setAuthView] = useState<'login' | 'register'>('login'); + const [openedProjectId, setOpenedProjectId] = useState(null); + + // Not authenticated → show Login or Register + if (!user || !token) { + return authView === 'login' + ? setAuthView('register')} /> + : setAuthView('login')} />; + } + + // Authenticated but no project opened → show Dashboard + if (!openedProjectId) { + return setOpenedProjectId(id)} />; + } + + // Authenticated + project opened → show CAD Editor + return setOpenedProjectId(null)} />; +}; + +export default App; diff --git a/frontend/src/components/LayerPanel.tsx b/frontend/src/components/LayerPanel.tsx index 13d34f9..1275333 100644 --- a/frontend/src/components/LayerPanel.tsx +++ b/frontend/src/components/LayerPanel.tsx @@ -1,11 +1,62 @@ import React, { useState } from 'react'; import type { LayerPanelProps } from '../types/ui.types'; -import type { CADLayer } from '../types/cad.types'; +import type { CADLayer, CADElement } from '../types/cad.types'; import type { TreeNode } from '../types/ui.types'; import TreeView from './TreeView'; +const elementTypeLabels: Record = { + line: 'Linie', + rect: 'Rechteck', + circle: 'Kreis', + arc: 'Bogen', + polyline: 'Polylinie', + polygon: 'Polygon', + text: 'Text', + dimension: 'Bemassung', + leader: 'Hinweislinie', + revcloud: 'Revisionswolke', + hatch: 'Schraffur', + chair: 'Stuhl', + 'seating-row': 'Reihe', + 'seating-block': 'Block', + table: 'Tisch', + stage: 'Buhne', + 'seating-template': 'Vorlage', +}; + +const layerIcon = ( + + + + + +); + +const elementIcons: Record = { + line: , + rect: , + circle: , + arc: , + polyline: , + polygon: , + text: , + dimension: , + leader: , + revcloud: , + hatch: , + chair: , + 'seating-row': , + 'seating-block': , + table: , + stage: , + 'seating-template': , +}; + +const defaultIcon = ; + const LayerPanel: React.FC = ({ layers, + elements = [], activeLayerId, onSelectLayer, onAddLayer, @@ -14,203 +65,117 @@ const LayerPanel: React.FC = ({ onRenameLayer, onDuplicateLayer, onToggleLock, - onAddSubLayer, onReorder, + onAddSubLayer, }) => { - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(''); - - // Build tree nodes from layers (parentId hierarchy) const buildTree = (parentId: string | null): TreeNode[] => { return layers .filter((l) => l.parentId === parentId) .sort((a, b) => a.sortOrder - b.sortOrder) - .map((l) => ({ - id: l.id, - name: l.name, - expanded: true, - active: l.id === activeLayerId, - children: buildTree(l.id), - })); + .map((l) => { + const layerElements = elements.filter((e) => e.layerId === l.id); + const elementNodes: TreeNode[] = layerElements.map((e) => ({ + id: e.id, + name: elementTypeLabels[e.type] || e.type, + icon: elementIcons[e.type] || defaultIcon, + expanded: false, + active: false, + children: [], + })); + const subLayerNodes = buildTree(l.id); + return { + id: l.id, + name: l.name, + icon: layerIcon, + expanded: true, + active: l.id === activeLayerId, + children: [...subLayerNodes, ...elementNodes], + count: layerElements.length > 0 ? layerElements.length : undefined, + } as TreeNode; + }); }; - const nodes = buildTree(null); + const tree = buildTree(null); - const handleStartRename = (layer: CADLayer) => { - setEditingId(layer.id); - setEditName(layer.name); - }; - - const handleConfirmRename = () => { - if (editingId && onRenameLayer) { - onRenameLayer(editingId, editName); + const handleAddSubLayer = (parentId: string) => { + if (onAddSubLayer) { + onAddSubLayer(parentId); } - setEditingId(null); - setEditName(''); - }; - - const renderLayerActions = (node: TreeNode) => { - const layer = layers.find((l) => l.id === node.id); - if (!layer) return null; - - return ( - - {/* Visibility toggle */} - - - {/* Lock toggle */} - {onToggleLock && ( - - )} - - {/* Add sub-layer button */} - {onAddSubLayer && ( - - )} - - ); - }; - - const renderLayerDetail = (layer: CADLayer) => { - if (editingId === layer.id) { - return ( -
- setEditName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleConfirmRename(); - if (e.key === 'Escape') setEditingId(null); - }} - autoFocus - style={{ flex: 1, fontSize: '12px' }} - /> - - -
- ); - } - - return ( -
- {/* Rename button */} - {onRenameLayer && ( - - )} - - {/* Duplicate button */} - {onDuplicateLayer && ( - - )} - - {/* Delete button */} - {onDeleteLayer && ( - - )} -
- ); - }; - - // Render expanded layer list with details - const renderLayerList = () => { - return layers - .sort((a, b) => a.sortOrder - b.sortOrder) - .map((layer) => ( -
-
onSelectLayer(layer.id)} - > - {layer.name} - {renderLayerActions({ id: layer.id, name: layer.name })} -
- {renderLayerDetail(layer)} -
- )); }; return ( -
-
- Ebenen-Struktur -
-
- { const layer = layers.find(l => l.id === node.id); return layer ? renderLayerDetail(layer) : null; }} /> +
+ {tree.length === 0 ? ( +
+ Keine Ebenen vorhanden +
+ ) : ( + node.icon} + renderActions={(node) => { + const layer = layers.find((l) => l.id === node.id); + if (!layer) return null; + return ( +
+ + + + + +
+ ); + }} + /> + )}
-
); }; diff --git a/frontend/src/components/RightSidebar.tsx b/frontend/src/components/RightSidebar.tsx index 4069e9b..c335294 100644 --- a/frontend/src/components/RightSidebar.tsx +++ b/frontend/src/components/RightSidebar.tsx @@ -17,6 +17,7 @@ const RightSidebar: React.FC = ({ activeLayerId, onSelectLayer, onAddLayer, onToggleLayer, onDeleteLayer, onRenameLayer, onDuplicateLayer, onToggleLock, onReorder, onAddSubLayer, + elements, onRenameBlock, onDuplicateBlock, onDeleteBlock, onSvgImport, onSaveGroupAsBlock, onBlockCategoryChange, onBlockSearch, onDragBlock, kiMessages, kiSuggestions, onKISend, onKISuggestionClick, kiLoading, onUpdateElement, @@ -56,7 +57,7 @@ const RightSidebar: React.FC = ({ }} />}
- {activePanel === 'layer' && {})} onAddLayer={onAddLayer ?? (() => {})} onToggleLayer={onToggleLayer ?? (() => {})} onDeleteLayer={onDeleteLayer} onRenameLayer={onRenameLayer} onDuplicateLayer={onDuplicateLayer} onToggleLock={onToggleLock} onReorder={onReorder} onAddSubLayer={onAddSubLayer} />} + {activePanel === 'layer' && {})} onAddLayer={onAddLayer ?? (() => {})} onToggleLayer={onToggleLayer ?? (() => {})} onDeleteLayer={onDeleteLayer} onRenameLayer={onRenameLayer} onDuplicateLayer={onDuplicateLayer} onToggleLock={onToggleLock} onReorder={onReorder} onAddSubLayer={onAddSubLayer} />}
{activePanel === 'library' && {})} onSearch={onBlockSearch ?? (() => {})} onDragBlock={onDragBlock ?? (() => {})} onRenameBlock={onRenameBlock} onDuplicateBlock={onDuplicateBlock} onDeleteBlock={onDeleteBlock} onSvgImport={onSvgImport} onSaveGroupAsBlock={onSaveGroupAsBlock} />} diff --git a/frontend/src/types/ui.types.ts b/frontend/src/types/ui.types.ts index af96a14..a948bdb 100644 --- a/frontend/src/types/ui.types.ts +++ b/frontend/src/types/ui.types.ts @@ -124,6 +124,7 @@ export interface RightSidebarProps { onPanelChange: (panel: RightPanel) => void; selectedElement: CADElement | null; layers: CADLayer[]; + elements?: CADElement[]; blocks: BlockDefinition[]; activeLayerId?: string; onSelectLayer?: (id: string) => void; @@ -162,6 +163,7 @@ export interface PropertiesPanelProps { export interface LayerPanelProps { layers: CADLayer[]; + elements?: CADElement[]; activeLayerId?: string; onSelectLayer: (id: string) => void; onAddLayer: () => void;