Files
web-cad/frontend/src/App.tsx
T

1801 lines
72 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, ImageElement } from './types/cad.types';
import { BlockService } from './services/blockService';
import { SeatingService } from './services/seatingService';
import type { ToolState } from './interaction';
import { GroupManager, type ElementGroup } from './tools/modification/GroupTool';
import Topbar from './components/Topbar';
import RibbonBar from './components/RibbonBar';
import LeftSidebar from './components/LeftSidebar';
import CanvasArea from './components/CanvasArea';
import RightSidebar from './components/RightSidebar';
import CommandLine from './components/CommandLine';
import StatusBar from './components/StatusBar';
import MobileDrawers from './components/MobileDrawers';
import BackgroundImport from './components/BackgroundImport';
import HistoryPanel from './components/HistoryPanel';
import SettingsModal from './components/SettingsModal';
import InlineTextEditor from './components/InlineTextEditor';
import { BackgroundService, type BackgroundConfig } from './services/backgroundService';
import { HistoryManager, type CADStateSnapshot, type HistoryEntry } from './history';
import { getCommandRegistry } from './services/commandRegistry';
import { importFile, type ImportResult } from './services/importService';
import { exportProject, downloadBlob, type ExportFormat } from './services/exportService';
import type { ProjectData } from './types/cad.types';
import { loadProjectDataTyped, createElementTyped, updateElement, deleteElement as apiDeleteElement, createLayerTyped, updateLayer, deleteLayer as apiDeleteLayer, createBlockTyped, updateBlock, deleteBlock as apiDeleteBlock, aiChat, getSetting, setSetting } from './services/api';
import { useYjsBinding } from './crdt';
import { registerBuiltinPlugins, pluginRegistry } from './plugins';
import type { PluginContext } from './plugins';
import type { UnitType } from './utils/format';
import './styles.css';
import './styles/auth.css';
import { useAuth } from './contexts/AuthContext';
import { Login } from './pages/Login';
import { Register } from './pages/Register';
import { Dashboard } from './pages/Dashboard';
// ─── Initial State ──────────────────────────────────────────
// Data is loaded from the backend on mount — start with empty state.
const initialLayers: CADLayer[] = [];
const initialBlocks: BlockDefinition[] = [];
const initialCommandHistory: CommandHistoryEntry[] = [
{ prefix: '·', text: 'Bereit', type: 'info' },
];
const initialKIMessages: KIMessage[] = [
{ id: 'ki-1', role: 'assistant', content: 'Hallo! Ich bin der KI Copilot. Wie kann ich helfen?' },
];
const initialKISuggestions: KISuggestion[] = [
{ id: 'sug-1', label: 'Bestuhlung automatisch generieren' },
{ id: 'sug-2', label: 'Maße analysieren' },
{ id: 'sug-3', label: 'Flächen berechnen' },
];
// ─── CAD Editor Component ───────────────────────────────
interface CADEditorProps {
projectId: string;
token: string;
onNavigateBack: () => void;
}
const CADEditor: React.FC<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,
token: token || undefined,
});
// 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);
// Units
const [unit, setUnit] = useState<UnitType>('mm');
const [gridSize, setGridSize] = useState<number>(20);
const [scaleFactor, setScaleFactor] = useState<number>(1);
// Canvas appearance
const [canvasBgColor, setCanvasBgColor] = useState<string>('#1e1e2e');
const [gridColor, setGridColor] = useState<string>('#3a3a4a');
const [rulerEnabled, setRulerEnabled] = useState<boolean>(true);
// Right sidebar
const [activeRightPanel, setActiveRightPanel] = useState<RightPanel>('tool');
const [selectedElement, setSelectedElement] = useState<CADElement | null>(null);
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
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
const createPluginContext = useCallback((): PluginContext => ({
addElement: (el) => setElements((prev) => [...prev, el]),
removeElement: (id) => setElements((prev) => prev.filter((e) => e.id !== id)),
updateElement: (id, props) => setElements((prev) => prev.map((e) => (e.id === id ? { ...e, ...props } : e))),
getElements: () => elementsRef.current,
getLayers: () => layersRef.current,
getActiveLayerId: () => activeLayerIdRef.current,
showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]),
log: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: `[Plugin] ${msg}`, type: 'info' }]),
}), []);
useEffect(() => {
pluginRegistry.setContext(createPluginContext());
registerBuiltinPlugins();
pluginRegistry.initDefaults();
}, [createPluginContext]);
// Status bar only count self as online when actually connected
const onlineCount = collab.status === 'connected' ? collab.cursors.length : 0;
// Mobile drawers
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
const [mobileRightOpen, setMobileRightOpen] = useState(false);
// Desktop sidebar collapse
const [leftSidebarCollapsed, setLeftSidebarCollapsed] = useState(false);
const [leftbarWidth, setLeftbarWidth] = useState(200);
const [rightbarWidth, setRightbarWidth] = useState(300);
const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [activeDrawerTab, setActiveDrawerTab] = useState<DrawerTab>('tool');
// Background
const [bgImportOpen, setBgImportOpen] = useState(false);
const [bgConfig, setBgConfig] = useState<BackgroundConfig | null>(null);
const bgServiceRef = React.useRef<BackgroundService>(new BackgroundService());
// Cleanup BackgroundService on unmount
useEffect(() => {
return () => {
bgServiceRef.current.dispose();
};
}, []);
// History panel
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
// Export format selector (cross-browser replacement for nwsave)
const [exportFormatOpen, setExportFormatOpen] = useState(false);
// Zoom command state — triggers zoom actions in CanvasArea via useEffect
const [zoomCommand, setZoomCommand] = useState<'in' | 'out' | 'fit' | '100' | null>(null);
// Elements + undo/redo (HistoryManager)
const [elements, setElements] = useState<CADElement[]>([]);
const historyManagerRef = React.useRef<HistoryManager>(new HistoryManager());
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
const [toolState, setToolState] = useState<ToolState | null>(null);
const seatingServiceRef = useRef<SeatingService>(new SeatingService());
const seatCount = useMemo(() => {
return seatingServiceRef.current.countSeats(elements).total;
}, [elements]);
// Groups
const groupManagerRef = React.useRef(new GroupManager());
const [groups, setGroups] = useState<ElementGroup[]>([]);
// Issue #8: Refs for PluginContext to avoid stale closures.
// These refs are updated on every render so PluginContext getters always return current state.
const elementsRef = useRef(elements);
elementsRef.current = elements;
const layersRef = useRef(layers);
layersRef.current = layers;
const blocksRef = useRef(blocks);
blocksRef.current = blocks;
const groupsRef = useRef(groups);
groupsRef.current = groups;
const bgConfigRef = useRef(bgConfig);
bgConfigRef.current = bgConfig;
const activeLayerIdRef = useRef(activeLayerId);
activeLayerIdRef.current = activeLayerId;
const selectedElementIdsRef = useRef(selectedElementIds);
selectedElementIdsRef.current = selectedElementIds;
// Clipboard (for copy/paste)
const clipboardRef = React.useRef<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();
// Sync restored state to Yjs CRDT to prevent collab overwrite
if (collab.status === 'connected') {
for (const el of snap.elements) collab.setElement(el);
for (const layer of snap.layers) collab.setLayer(layer);
for (const block of snap.blocks) collab.setBlock(block);
}
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rückgängig: letzte Aktion', type: 'info' }]);
}
}, [restoreSnapshot, syncHistory, collab]);
const handleRedo = useCallback(() => {
const snap = historyManagerRef.current.redo();
if (snap) {
restoreSnapshot(snap);
syncHistory();
// Sync restored state to Yjs CRDT to prevent collab overwrite
if (collab.status === 'connected') {
for (const el of snap.elements) collab.setElement(el);
for (const layer of snap.layers) collab.setLayer(layer);
for (const block of snap.blocks) collab.setBlock(block);
}
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Wiederherstellen: letzte Aktion', type: 'info' }]);
}
}, [restoreSnapshot, syncHistory, collab]);
const pushHistorySnapshot = useCallback((label: string) => {
historyManagerRef.current.pushSnapshot({
elements,
layers,
blocks,
groups,
bgConfig,
}, label);
syncHistory();
}, [elements, layers, blocks, groups, bgConfig, syncHistory]);
// Initialize HistoryManager with initial state on mount
const historyInitRef = React.useRef(false);
React.useEffect(() => {
if (historyInitRef.current) return;
historyInitRef.current = true;
historyManagerRef.current.initialize({
elements: [],
layers: initialLayers,
blocks: initialBlocks,
groups: [],
bgConfig: null,
});
syncHistory();
}, [syncHistory]);
// Load project data from backend on mount
const [dataLoaded, setDataLoaded] = useState(false);
React.useEffect(() => {
if (!projectId || !token) return;
let cancelled = false;
(async () => {
try {
setSavedStatus('Lädt…');
const data = await loadProjectDataTyped(token, projectId);
if (cancelled) return;
setProjectName(data.project.name);
setDrawingId(data.drawing?.id || null);
if (data.elements.length > 0) setElements(data.elements);
if (data.layers.length > 0) setLayers(data.layers);
if (data.blocks.length > 0) setBlocks(data.blocks);
// Ensure activeLayerId matches a real layer
if (data.layers.length > 0) {
const hasActiveLayer = data.layers.some(l => l.id === 'layer-0');
if (!hasActiveLayer) {
setActiveLayerId(data.layers[0].id);
}
}
// Create default layer if backend has none
if (data.layers.length === 0 && data.drawing) {
const defaultLayer: CADLayer = {
id: 'layer-0',
name: 'Layer 0',
visible: true,
locked: false,
color: '#ffffff',
lineType: 'solid',
transparency: 0,
sortOrder: 0,
parentId: null,
};
createLayerTyped(token, data.drawing.id, defaultLayer).catch(() => {});
setLayers([defaultLayer]);
setActiveLayerId('layer-0');
}
setSavedStatus('gespeichert');
setDataLoaded(true);
// Re-initialize HistoryManager with loaded project state so undo does not jump back to empty initial state
historyManagerRef.current.initialize({
elements: data.elements,
layers: data.layers.length > 0 ? data.layers : initialLayers,
blocks: data.blocks,
groups: [],
bgConfig: null,
});
syncHistory();
// Push initial data to Yjs CRDT after load
// Issue #7: Only push to CRDT if backend data actually has content.
// Pushing empty arrays would wipe existing CRDT data from other connected clients.
const bgConfigs = bgConfig ? [bgConfig] : [];
const hasContent = data.elements.length > 0 || data.layers.length > 0 || data.blocks.length > 0 || groups.length > 0 || bgConfigs.length > 0;
if (collab.status === 'connected' && hasContent) {
collab.loadFromState({
elements: data.elements,
layers: data.layers,
blocks: data.blocks,
groups,
bgConfigs,
});
}
} catch (err) {
// Ignore race condition errors from React StrictMode double-render
if (cancelled) return;
if (err instanceof Error && err.message.includes('Superseded')) return;
console.error('Failed to load project:', err);
setSavedStatus('Fehler beim Laden');
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, token]);
// Load unit settings from backend on mount
React.useEffect(() => {
if (!token) return;
let cancelled = false;
(async () => {
try {
const [unitSetting, gridSetting, scaleSetting] = await Promise.all([
getSetting(token, 'cad.unit').catch(() => null),
getSetting(token, 'cad.gridSize').catch(() => null),
getSetting(token, 'cad.scaleFactor').catch(() => null),
]);
if (cancelled) return;
if (unitSetting && (unitSetting.value === 'mm' || unitSetting.value === 'cm' || unitSetting.value === 'm')) {
setUnit(unitSetting.value as UnitType);
}
if (gridSetting) {
const gs = parseFloat(gridSetting.value);
if (!isNaN(gs) && gs > 0) setGridSize(gs);
}
if (scaleSetting) {
const sf = parseFloat(scaleSetting.value);
if (!isNaN(sf) && sf > 0) setScaleFactor(sf);
}
} catch {
// Settings not yet stored — use defaults
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
// Save unit settings to backend when they change
const handleUnitChange = useCallback((newUnit: UnitType) => {
setUnit(newUnit);
if (token) setSetting(token, 'cad.unit', newUnit).catch(() => {});
}, [token]);
const handleGridSizeChange = useCallback((newSize: number) => {
setGridSize(newSize);
if (token) setSetting(token, 'cad.gridSize', String(newSize)).catch(() => {});
}, [token]);
const handleScaleFactorChange = useCallback((newFactor: number) => {
setScaleFactor(newFactor);
if (token) setSetting(token, 'cad.scaleFactor', String(newFactor)).catch(() => {});
}, [token]);
// Sync remote → local: when Yjs data changes from other users, update local state
React.useEffect(() => {
if (collab.status !== 'connected') return;
// Skip if collab data is empty (not yet loaded)
if (collab.elements.length === 0 && collab.layers.length === 0 && collab.blocks.length === 0) return;
// Only update local state if remote data differs AND local is not ahead of remote
// (prevents race condition where local element is overwritten by stale Yjs sync)
// Merge: update local elements with remote version when properties differ, add remote-only elements
const localIds = new Set(elements.map(e => e.id));
const remoteIds = new Set(collab.elements.map(e => e.id));
const remoteMap = new Map(collab.elements.map(e => [e.id, e]));
let changed = false;
const merged = elements.map(el => {
const remote = remoteMap.get(el.id);
if (remote && JSON.stringify(remote) !== JSON.stringify(el)) {
changed = true;
return remote;
}
return el;
});
// Add remote-only elements
collab.elements.forEach(el => {
if (!localIds.has(el.id)) {
changed = true;
merged.push(el);
}
});
if (changed) {
setElements(merged);
}
// Merge layers: local-first, update changed from remote, append remote-only
if (collab.layers.length > 0) {
const localLayerMap = new Map(layers.map(l => [l.id, l]));
const remoteLayerMap = new Map(collab.layers.map(l => [l.id, l]));
const mergedLayers = layers.map(ll => {
const remote = remoteLayerMap.get(ll.id);
return remote ? (JSON.stringify(remote) !== JSON.stringify(ll) ? remote : ll) : ll;
});
collab.layers.forEach(rl => {
if (!localLayerMap.has(rl.id)) mergedLayers.push(rl);
});
if (mergedLayers.length !== layers.length || mergedLayers.some((ml, i) => ml !== layers[i])) {
setLayers(mergedLayers);
}
// Ensure activeLayerId matches a real layer after collab merge
const hasActiveInMerged = mergedLayers.some(l => l.id === activeLayerId);
if (!hasActiveInMerged && mergedLayers.length > 0) {
setActiveLayerId(mergedLayers[0].id);
}
}
// Merge blocks: local-first, update changed from remote, append remote-only
if (collab.blocks.length > 0) {
const localBlockMap = new Map(blocks.map(b => [b.id, b]));
const remoteBlockMap = new Map(collab.blocks.map(b => [b.id, b]));
const mergedBlocks = blocks.map(lb => {
const remote = remoteBlockMap.get(lb.id);
return remote ? (JSON.stringify(remote) !== JSON.stringify(lb) ? remote : lb) : lb;
});
collab.blocks.forEach(rb => {
if (!localBlockMap.has(rb.id)) mergedBlocks.push(rb);
});
if (mergedBlocks.length !== blocks.length || mergedBlocks.some((mb, i) => mb !== blocks[i])) {
setBlocks(mergedBlocks);
}
}
// Sync groups from remote → local
if (collab.groups.length > 0) {
const sameGroups = collab.groups.length === groups.length &&
collab.groups.every((g, i) => g.id === groups[i]?.id);
if (!sameGroups) setGroups(collab.groups);
}
// Sync bgConfig from remote → local
if (collab.bgConfigs.length > 0) {
const remoteBg = collab.bgConfigs[0];
if (!bgConfig || remoteBg.src !== bgConfig.src || remoteBg.scale !== bgConfig.scale ||
remoteBg.offsetX !== bgConfig.offsetX || remoteBg.offsetY !== bgConfig.offsetY ||
remoteBg.visible !== bgConfig.visible || remoteBg.opacity !== bgConfig.opacity) {
setBgConfig(remoteBg);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collab.elements, collab.layers, collab.blocks, collab.groups, collab.bgConfigs, collab.status]);
// Sync local → remote: push local groups to Yjs when groups change
React.useEffect(() => {
if (collab.status !== 'connected') return;
for (const group of groups) {
collab.setGroup(group);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groups, collab.status]);
// Sync local → remote: push local bgConfig to Yjs when bgConfig changes
React.useEffect(() => {
if (collab.status !== 'connected') return;
if (bgConfig) {
collab.setBgConfig(bgConfig.name || 'default', bgConfig);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bgConfig, collab.status]);
// Sync local → remote: push local layers to Yjs when layers change
React.useEffect(() => {
if (collab.status !== 'connected') return;
for (const layer of layers) collab.setLayer(layer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layers, collab.status]);
// Sync local → remote: push local blocks to Yjs when blocks change
React.useEffect(() => {
if (collab.status !== 'connected') return;
for (const block of blocks) collab.setBlock(block);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blocks, collab.status]);
const handleElementCreated = useCallback((el: CADElement) => {
const elWithLayer = el.layerId ? el : { ...el, layerId: activeLayerId };
setElements((prev) => {
const newElements = [...prev, elWithLayer];
historyManagerRef.current.pushSnapshot({
elements: newElements, layers, blocks, groups, bgConfig,
}, 'Element erstellt');
return newElements;
});
syncHistory();
// Push to Yjs CRDT for real-time sync
collab.setElement(elWithLayer);
// Save to backend
if (drawingId && token) {
setSavedStatus('Speichert…');
createElementTyped(token, drawingId, elWithLayer).then(() => {
setSavedStatus('gespeichert');
}).catch((err) => {
console.error('Failed to save element:', err);
setSavedStatus('Fehler beim Speichern');
});
}
}, [layers, blocks, groups, bgConfig, syncHistory, drawingId, token, collab, activeLayerId]);
const handleToggleElementVisible = useCallback((id: string) => {
const target = elements.find(e => e.id === id);
if (!target) return;
const updatedEl: CADElement = { ...target, properties: { ...target.properties, visible: target.properties?.visible === false ? true : false } };
setElements((prev) => prev.map((e) => e.id === id ? updatedEl : e));
// Call API outside the state updater
if (token) {
setSavedStatus('Speichert…');
updateElement(token, updatedEl.id, updatedEl).then(() => setSavedStatus('gespeichert')).catch((err) => {
console.error('Failed to toggle element visibility:', err);
setSavedStatus('Fehler beim Speichern');
});
}
}, [elements, token]);
const handleElementsDeleted = useCallback((ids: string[]) => {
setElements((prev) => {
const newElements = prev.filter((e) => !ids.includes(e.id));
historyManagerRef.current.pushSnapshot({
elements: newElements, layers, blocks, groups, bgConfig,
}, `${ids.length} Element(e) gelöscht`);
return newElements;
});
syncHistory();
// Push deletions to Yjs CRDT for real-time sync
ids.forEach(id => collab.deleteElement(id));
// Delete from backend
if (token) {
setSavedStatus('Speichert…');
Promise.all(ids.map(id => apiDeleteElement(token, id))).then(() => {
setSavedStatus('gespeichert');
}).catch((err) => {
console.error('Failed to delete elements:', err);
setSavedStatus('Fehler beim Speichern');
});
}
}, [layers, blocks, groups, bgConfig, syncHistory, token, collab]);
const handleElementsModified = useCallback((modified: CADElement[]) => {
setElements((prev) => {
const newElements = prev.map((e) => {
const found = modified.find((m) => m.id === e.id);
return found ?? e;
});
historyManagerRef.current.pushSnapshot({
elements: newElements, layers, blocks, groups, bgConfig,
}, `${modified.length} Element(e) geändert`);
return newElements;
});
syncHistory();
// Push modifications to Yjs CRDT for real-time sync
modified.forEach(el => collab.setElement(el));
// Update in backend
if (token) {
setSavedStatus('Speichert…');
Promise.all(modified.map(el => updateElement(token, el.id, el))).then(() => {
setSavedStatus('gespeichert');
}).catch((err) => {
console.error('Failed to update elements:', err);
setSavedStatus('Fehler beim Speichern');
});
}
}, [layers, blocks, groups, bgConfig, syncHistory, token, collab]);
const lastCursorUpdateRef = useRef(0);
const handleCursorMoved = useCallback((x: number, y: number) => {
const now = performance.now();
if (now - lastCursorUpdateRef.current < 33) return; // throttle to ~30fps
lastCursorUpdateRef.current = now;
setCursorPos({ x, y });
collab.setCursor(x, y);
}, [collab]);
const handleToolStateChanged = useCallback((state: ToolState) => {
setToolState(state);
}, []);
// Inline text editor state
const [editingTextElement, setEditingTextElement] = useState<CADElement | null>(null);
const handleTextEdit = useCallback((el: CADElement) => {
setEditingTextElement(el);
}, []);
const handleTextEditorSubmit = useCallback((text: string) => {
if (editingTextElement && text.trim() !== '') {
const updatedEl = { ...editingTextElement, properties: { ...editingTextElement.properties, text } };
setElements((prev) => prev.map((e) =>
e.id === editingTextElement.id ? updatedEl : e
));
// Push to Yjs CRDT for real-time sync
collab.setElement(updatedEl);
// Update in backend
if (token) {
setSavedStatus('Speichert…');
updateElement(token, updatedEl.id, updatedEl).then(() => {
setSavedStatus('gespeichert');
}).catch((err) => {
console.error('Failed to save text edit:', err);
setSavedStatus('Fehler beim Speichern');
});
}
}
setEditingTextElement(null);
}, [editingTextElement, collab, token]);
const handleTextEditorCancel = useCallback(() => {
setEditingTextElement(null);
}, []);
const handleCommandTrigger = useCallback((msg: string) => {
// Route group/ungroup actions from keyboard shortcuts
if (msg === 'group') {
const gm = groupManagerRef.current;
const ids = selectedElementIdsRef.current;
if (ids.length < 2) {
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppieren: Mindestens 2 Elemente auswählen', type: 'info' }]);
return;
}
const group = gm.createGroup(ids);
setGroups(gm.getGroups());
collab.setGroup(group);
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe erstellt: ${group.name} (${ids.length} Elemente)`, type: 'info' }]);
return;
}
if (msg === 'ungroup') {
const gm = groupManagerRef.current;
const allGroups = gm.getGroups();
if (allGroups.length === 0) {
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Degruppieren: Keine Gruppe vorhanden', type: 'info' }]);
return;
}
const selIds = selectedElementIdsRef.current;
let targetGroup = allGroups.find(g => g.elementIds.some(id => selIds.includes(id)));
if (!targetGroup) targetGroup = allGroups[allGroups.length - 1];
gm.ungroup(targetGroup.id);
setGroups(gm.getGroups());
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe aufgelöst: ${targetGroup.name}`, type: 'info' }]);
return;
}
setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]);
}, [collab]);
// Block management handlers
const handleRenameBlock = useCallback((id: string, name: string) => {
setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, name } : b));
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block umbenannt: ${name}`, type: 'info' }]);
// Persist rename to backend
if (token) {
updateBlock(token, id, { name }).catch((err) => {
console.error('Failed to save block rename:', err);
});
}
}, [token]);
const handleDuplicateBlock = useCallback((id: string) => {
setBlocks((prev) => {
const block = prev.find((b) => b.id === id);
if (!block) return prev;
const newId = `blk-${Date.now()}`;
const copy: BlockDefinition = {
...block,
id: newId,
name: `${block.name} (Kopie)`,
elements: block.elements.map((el) => ({ ...el, id: `${el.id}_copy_${Date.now()}` })),
};
// Persist duplicate to backend
if (drawingId && token) {
createBlockTyped(token, drawingId, copy).catch((err) => {
console.error('Failed to save duplicated block:', err);
});
}
return [...prev, copy];
});
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block dupliziert', type: 'info' }]);
}, [drawingId, token]);
const handleDeleteBlock = useCallback((id: string) => {
setBlocks((prev) => prev.filter((b) => b.id !== id));
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block gelöscht', type: 'info' }]);
// Delete from backend
if (token) {
apiDeleteBlock(token, id).catch((err) => {
console.error('Failed to delete block:', err);
});
}
}, [token]);
const handleSvgImport = useCallback((svg: string, name: string, category: string) => {
const svc = new BlockService();
const block = svc.importSVG(svg, name, category);
setBlocks((prev) => [...prev, block]);
// Push to Yjs CRDT for real-time sync
collab.setBlock(block);
// Persist to backend
if (drawingId && token) {
setSavedStatus('Speichert…');
createBlockTyped(token, drawingId, block)
.then(() => setSavedStatus('gespeichert'))
.catch((err) => {
console.error('Failed to save imported SVG block:', err);
setSavedStatus('Fehler beim Speichern');
});
}
setCommandHistory((prev) => [...prev, { prefix: '·', text: `SVG importiert: ${name}`, type: 'info' }]);
}, [collab, drawingId, token]);
const handleSaveGroupAsBlock = useCallback((name: string) => {
const selectedIds = selectedElementIdsRef.current;
const selectedEls = selectedIds.length > 0
? elementsRef.current.filter(e => selectedIds.includes(e.id))
: (selectedElement ? [selectedElement] : []);
if (selectedEls.length === 0) {
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Keine Elemente ausgewählt', type: 'info' }]);
return;
}
setBlocks((prev) => {
const newId = `blk_grp_${Date.now()}`;
const block: BlockDefinition = {
id: newId,
name,
description: 'Aus Auswahl erstellt',
category: 'Custom',
elements: selectedEls.map(el => ({ ...el, id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` })),
};
// Push to Yjs CRDT for real-time sync
collab.setBlock(block);
// Persist to backend
if (drawingId && token) {
createBlockTyped(token, drawingId, block).catch((err) => {
console.error('Failed to save group block:', err);
});
}
return [...prev, block];
});
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block erstellt: ${name} (${selectedEls.length} Elemente)`, type: 'info' }]);
}, [selectedElement, collab, drawingId, token]);
const handleBlockCategoryChange = useCallback((cat: string) => {
setBlockCategory(cat);
}, []);
const handleTemplateSelect = useCallback((templateName: string | null) => {
setSelectedTemplate(templateName);
if (templateName) {
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Vorlage gewählt: ${templateName} Klicken zum Platzieren`, type: 'info' }]);
}
}, []);
const handleBlockSearch = useCallback((query: string) => {
// BlockLibrary handles search filtering locally — this callback is for
// any App-level side effects of block search (e.g. logging, analytics).
if (query.trim()) {
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block-Suche: ${query}`, type: 'info' }]);
}
}, []);
const handleDragBlock = useCallback((blockId: string) => {
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block gezogen: ${blockId}`, type: 'info' }]);
}, []);
const handleBlockDrop = useCallback((blockId: string, x: number, y: number) => {
const svc = new BlockService();
blocks.forEach(b => svc.addBlock(b));
const instance = svc.createInstance(blockId, x, y, activeLayerId);
if (instance) {
setElements((prev) => [...prev, instance]);
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block platziert: ${blockId} bei (${x.toFixed(2)}, ${y.toFixed(2)})`, type: 'info' }]);
// Push to Yjs CRDT for real-time sync
if (collab.status === 'connected') {
collab.setElement(instance);
}
// Save to backend
if (drawingId && token) {
setSavedStatus('Speichert…');
createElementTyped(token, drawingId, instance).then(() => {
setSavedStatus('gespeichert');
}).catch((err) => {
console.error('Failed to save block instance:', err);
setSavedStatus('Fehler beim Speichern');
});
}
}
}, [blocks, activeLayerId, collab, drawingId, token]);
const handleSelectionChange = useCallback((selectedIds: string[]) => {
setSelectedElementIds(selectedIds);
if (selectedIds.length === 1) {
const el = elements.find(e => e.id === selectedIds[0]);
setSelectedElement(el ?? null);
} else if (selectedIds.length === 0) {
setSelectedElement(null);
} else {
// Multiple elements selected — keep the first one as a representative for the properties panel
const el = elements.find(e => e.id === selectedIds[0]);
setSelectedElement(el ?? null);
}
}, [elements]);
const handleImport = useCallback(async (file: File) => {
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiere ${file.name}...`, type: 'info' }]);
const result = await importFile(file);
if (result.success) {
setElements((prev) => [...prev, ...result.elements]);
// Push imported elements to Yjs and backend
for (const el of result.elements) {
collab.setElement(el);
}
if (drawingId && token) {
setSavedStatus('Speichert…');
Promise.all(result.elements.map(el => createElementTyped(token, drawingId, el)))
.then(() => setSavedStatus('gespeichert'))
.catch((err) => {
console.error('Failed to save imported elements:', err);
setSavedStatus('Fehler beim Speichern');
});
}
if (result.layers && result.layers.length > 0) {
const existingIds = new Set(layers.map(l => l.id));
const newLayers = result.layers.filter(l => !existingIds.has(l.id));
setLayers((prev) => [...prev, ...newLayers]);
// Push imported layers to Yjs and backend
for (const layer of newLayers) {
collab.setLayer(layer);
}
if (drawingId && token) {
Promise.all(newLayers.map(l => createLayerTyped(token, drawingId, l)))
.catch((err) => console.error('Failed to save imported layers:', err));
}
}
if (result.blocks && result.blocks.length > 0) {
setBlocks((prev) => [...prev, ...result.blocks!]);
// Push imported blocks to Yjs and backend
for (const block of result.blocks) {
collab.setBlock(block);
}
if (drawingId && token) {
Promise.all(result.blocks.map(b => createBlockTyped(token, drawingId, b)))
.catch((err) => console.error('Failed to save imported blocks:', err));
}
}
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiert: ${result.elements.length} Elemente aus ${file.name}`, type: 'info' }]);
if (result.warnings.length > 0) {
result.warnings.forEach(w => setCommandHistory((prev) => [...prev, { prefix: '·', text: w, type: 'info' }]));
}
} else {
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Import fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]);
}
}, [drawingId, token, collab, layers]);
const handleExport = useCallback(async (format: ExportFormat) => {
const projectData: ProjectData = {
version: '1.0',
name: projectName,
layers,
elements,
blocks,
};
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiere als ${format.toUpperCase()}...`, type: 'info' }]);
const result = await exportProject(projectData, { format });
if (result.success && result.blob) {
downloadBlob(result.blob, result.filename);
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiert: ${result.filename}`, type: 'info' }]);
} else {
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Export fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]);
}
}, [layers, elements, blocks]);
const handleRibbonAction = useCallback((action: string) => {
setCommandHistory((prev) => [...prev, { prefix: '', text: action, type: 'command' }]);
// ─── File actions ───
if (action === 'new') {
onNavigateBack();
return;
}
if (action === 'open') {
onNavigateBack();
return;
}
if (action === 'save') {
setSavedStatus('Gespeichert');
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Projekt gespeichert', type: 'info' }]);
// Save elements to backend if drawingId exists
if (drawingId && token) {
elements.forEach((el) => {
updateElement(token, el.id, el).catch(() => {});
});
}
return;
}
if (action === 'import') {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.dxf,.svg,.json';
input.onchange = () => {
if (input.files && input.files[0]) {
handleImport(input.files[0]);
}
};
input.click();
return;
}
if (action === 'export') {
setExportFormatOpen(true);
return;
}
// ─── Edit actions ───
if (action === 'undo') { handleUndo(); return; }
if (action === 'redo') { handleRedo(); return; }
if (action === 'copy') {
const ids = selectedElementIdsRef.current;
const selected = ids.length > 0 ? elements.filter(e => ids.includes(e.id)) : [];
const clip = selected.length > 0 ? selected : elements.slice(0, 1);
clipboardRef.current = clip;
setCommandHistory((prev) => [...prev, { prefix: '·', text: `${clip.length} Element(e) kopiert`, type: 'info' }]);
return;
}
if (action === 'paste') {
if (clipboardRef.current && clipboardRef.current.length > 0) {
const offset = 20;
const pasted = clipboardRef.current.map((el, i) => ({
...el,
id: `el-${Date.now()}-${i}`,
x: (el.x ?? 0) + offset,
y: (el.y ?? 0) + offset,
}));
setElements((prev) => [...prev, ...pasted]);
setCommandHistory((prev) => [...prev, { prefix: '·', text: `${pasted.length} Element(e) eingefügt`, type: 'info' }]);
// Push pasted elements to Yjs CRDT and backend
for (const el of pasted) {
collab.setElement(el);
}
if (drawingId && token) {
setSavedStatus('Speichert…');
Promise.all(pasted.map(el => createElementTyped(token, drawingId, el))).then(() => {
setSavedStatus('gespeichert');
}).catch((err) => {
console.error('Failed to save pasted elements:', err);
setSavedStatus('Fehler beim Speichern');
});
}
} else {
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zwischenablage leer', type: 'info' }]);
}
return;
}
// ─── Group/Ungroup actions ───
if (action === 'group') {
const gm = groupManagerRef.current;
const ids = selectedElementIdsRef.current;
if (ids.length < 2) {
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppieren: Mindestens 2 Elemente auswählen', type: 'info' }]);
return;
}
const group = gm.createGroup(ids);
const newGroups = gm.getGroups();
setGroups(newGroups);
collab.setGroup(group);
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe erstellt: ${group.name} (${ids.length} Elemente)`, type: 'info' }]);
return;
}
if (action === 'ungroup') {
const gm = groupManagerRef.current;
const allGroups = gm.getGroups();
if (allGroups.length === 0) {
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Degruppieren: Keine Gruppe vorhanden', type: 'info' }]);
return;
}
// Find the group that contains any selected element
const selIds = selectedElementIdsRef.current;
let targetGroup = allGroups.find(g => g.elementIds.some(id => selIds.includes(id)));
if (!targetGroup) targetGroup = allGroups[allGroups.length - 1];
gm.ungroup(targetGroup.id);
setGroups(gm.getGroups());
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe aufgelöst: ${targetGroup.name}`, type: 'info' }]);
return;
}
// ─── Insert actions (set active tool) ───
if (action === 'insert-line') { setActiveTool('line'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Linie-Werkzeug aktiv', type: 'info' }]); return; }
if (action === 'insert-rect') { setActiveTool('rect'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rechteck-Werkzeug aktiv', type: 'info' }]); return; }
if (action === 'insert-circle') { setActiveTool('circle'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Kreis-Werkzeug aktiv', type: 'info' }]); return; }
if (action === 'insert-text') { setActiveTool('text'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Text-Werkzeug aktiv', type: 'info' }]); return; }
if (action === 'insert-freehand') { setActiveTool('polyline'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Freihand-Werkzeug aktiv', type: 'info' }]); return; }
if (action === 'insert-image') {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = () => {
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = () => {
const imgEl: ImageElement = {
id: `el-${Date.now()}`,
type: 'image',
layerId: activeLayerId,
x: 0, y: 0,
width: 200,
height: 200,
properties: { src: reader.result as string },
};
setElements((prev) => [...prev, imgEl]);
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Bild eingefügt', type: 'info' }]);
// Push image element to Yjs CRDT for real-time sync
collab.setElement(imgEl);
// Save to backend
if (drawingId && token) {
setSavedStatus('Speichert…');
createElementTyped(token, drawingId, imgEl).then(() => {
setSavedStatus('gespeichert');
}).catch((err) => {
console.error('Failed to save image element:', err);
setSavedStatus('Fehler beim Speichern');
});
}
};
reader.readAsDataURL(input.files[0]);
}
};
input.click();
return;
}
// ─── Format actions ───
if (action.startsWith('format-')) {
const formatType = action.replace('format-', '');
const selectedIds = selectedElementIdsRef.current;
const selected = selectedIds.length > 0
? elements.filter(e => selectedIds.includes(e.id))
: (selectedElement ? [selectedElement] : []);
if (selected.length === 0) {
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Format: Keine Elemente ausgewählt', type: 'info' }]);
return;
}
const modified = selected.map(el => {
const props = { ...el.properties };
switch (formatType) {
case 'stroke-width':
props.strokeWidth = (props.strokeWidth ?? 1) + 0.5;
break;
case 'stroke-color':
props.stroke = props.stroke === '#ff0000' ? '#00ff00' : '#ff0000';
break;
case 'fill-color':
props.fill = props.fill === '#ff0000' ? '#00ff00' : '#ff0000';
break;
case 'stroke-style':
props.lineType = props.lineType === 'solid' ? 'dashed' : 'solid';
break;
case 'align-left':
return { ...el, x: selected.reduce((min, s) => Math.min(min, s.x), Infinity) };
case 'align-center':
const cx = selected.reduce((sum, s) => sum + s.x, 0) / selected.length;
return { ...el, x: cx };
case 'align-right':
return { ...el, x: selected.reduce((max, s) => Math.max(max, s.x), -Infinity) };
default:
return el;
}
return { ...el, properties: props };
});
handleElementsModified(modified);
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Format: ${formatType} auf ${modified.length} Element(e) angewendet`, type: 'info' }]);
return;
}
// ─── View actions ───
if (action === 'zoom-fit') {
setZoomCommand('fit');
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]);
return;
}
if (action === 'zoom-100') {
setZoomCommand('100');
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: 100%', type: 'info' }]);
return;
}
if (action === 'grid') {
setGridEnabled((p) => !p);
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Grid ${gridEnabled ? 'aus' : 'ein'}`, type: 'info' }]);
return;
}
if (action === 'layer') { setActiveRightPanel('layer'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Layer-Manager geöffnet', type: 'info' }]); return; }
// ─── Tools actions ───
if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; }
if (action === 'history') { setHistoryPanelOpen((prev) => !prev); return; }
// ─── Background ───
if (action === 'background-import') { setBgImportOpen(true); return; }
// ─── KI actions ───
if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; }
if (action === 'ki-draw') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Zeichnen: Beschreibung eingeben', type: 'info' }]); return; }
if (action === 'ki-analyze') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Analyse gestartet', type: 'info' }]); return; }
}, [handleUndo, handleRedo, handleElementsModified, handleImport, handleExport, onNavigateBack, drawingId, token, elements, activeLayerId, gridEnabled, collab, selectedElement]);
const handleToolChange = useCallback((tool: string) => {
setActiveTool(tool);
setMobileLeftOpen(false);
}, []);
const handleViewChange = useCallback((mode: ViewMode) => {
setViewMode(mode);
}, []);
const handleToggleGrid = useCallback(() => setGridEnabled((p) => !p), []);
const handleToggleOrtho = useCallback(() => setOrthoEnabled((p) => !p), []);
const handleToggleSnap = useCallback(() => setSnapEnabled((p) => !p), []);
const handleTogglePolar = useCallback(() => setPolarEnabled((p) => !p), []);
// Layer handlers
const handleSelectLayer = useCallback((id: string) => setActiveLayerId(id), []);
const handleSelectElement = useCallback((id: string) => {
setSelectedElementIds([id]);
const el = elements.find(e => e.id === id);
setSelectedElement(el ?? null);
}, [elements]);
const handleAddLayer = useCallback(() => {
setLayers((prev) => {
const newId = `layer-${Date.now()}`;
const newLayer: CADLayer = {
id: newId, name: `Layer ${prev.length + 1}`, visible: true, locked: false,
color: '#ffffff', lineType: 'solid', transparency: 0,
sortOrder: prev.length, parentId: null,
};
// Save to backend
if (drawingId && token) {
createLayerTyped(token, drawingId, newLayer).catch((err) => {
console.error('Failed to save layer:', err);
});
}
return [...prev, newLayer];
});
}, [drawingId, token]);
const handleToggleLayer = useCallback((id: string) => {
setLayers((prev) => {
const updated = prev.map((l) => l.id === id ? { ...l, visible: !l.visible } : l);
const changed = updated.find((l) => l.id === id);
if (changed) {
collab.setLayer(changed);
if (token) {
updateLayer(token, id, { visible: changed.visible }).catch((err) => {
console.error('Failed to save layer visibility:', err);
});
}
}
return updated;
});
}, [token, collab]);
const handleDeleteLayer = useCallback((id: string) => {
setLayers((prev) => prev.filter((l) => l.id !== id));
// Delete from backend
if (token) {
apiDeleteLayer(token, id).catch((err) => {
console.error('Failed to delete layer:', err);
});
}
}, [token]);
const handleRenameLayer = useCallback((id: string, name: string) => {
setLayers((prev) => {
const updated = prev.map((l) => l.id === id ? { ...l, name } : l);
const changed = updated.find((l) => l.id === id);
if (changed) {
collab.setLayer(changed);
if (token) {
updateLayer(token, id, { name }).catch((err) => {
console.error('Failed to save layer rename:', err);
});
}
}
return updated;
});
}, [token, collab]);
const handleDuplicateLayer = useCallback((id: string) => {
setLayers((prev) => {
const layer = prev.find((l) => l.id === id);
if (!layer) return prev;
const newId = `layer-${Date.now()}`;
const copy: CADLayer = { ...layer, id: newId, name: `${layer.name} (Kopie)`, sortOrder: prev.length };
collab.setLayer(copy);
if (drawingId && token) {
createLayerTyped(token, drawingId, copy).catch((err) => {
console.error('Failed to save duplicated layer:', err);
});
}
return [...prev, copy];
});
}, [drawingId, token, collab]);
const handleToggleLock = useCallback((id: string) => {
setLayers((prev) => {
const updated = prev.map((l) => l.id === id ? { ...l, locked: !l.locked } : l);
const changed = updated.find((l) => l.id === id);
if (changed) {
collab.setLayer(changed);
if (token) {
updateLayer(token, id, { locked: changed.locked }).catch((err) => {
console.error('Failed to save layer lock state:', err);
});
}
}
return updated;
});
}, [token, collab]);
const handleUpdateLayerColor = useCallback((id: string, color: string) => {
setLayers((prev) => {
const updated = prev.map((l) => l.id === id ? { ...l, color } : l);
const changed = updated.find((l) => l.id === id);
if (changed) {
collab.setLayer(changed);
if (token) {
updateLayer(token, id, { color }).catch((err) => {
console.error('Failed to save layer color:', err);
});
}
}
return updated;
});
}, [token, collab]);
const handleUpdateLayerLineType = useCallback((id: string, lineType: 'solid' | 'dashed' | 'dotted') => {
setLayers((prev) => {
const updated = prev.map((l) => l.id === id ? { ...l, lineType } : l);
const changed = updated.find((l) => l.id === id);
if (changed) {
collab.setLayer(changed);
if (token) {
updateLayer(token, id, { lineType }).catch((err) => {
console.error('Failed to save layer line type:', err);
});
}
}
return updated;
});
}, [token, collab]);
const handleUpdateLayerTransparency = useCallback((id: string, transparency: number) => {
setLayers((prev) => {
const updated = prev.map((l) => l.id === id ? { ...l, transparency } : l);
const changed = updated.find((l) => l.id === id);
if (changed) {
collab.setLayer(changed);
if (token) {
updateLayer(token, id, { transparency }).catch((err) => {
console.error('Failed to save layer transparency:', err);
});
}
}
return updated;
});
}, [token, collab]);
const handleZoomIn = useCallback(() => {
setZoomCommand('in');
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: vergrößert', type: 'info' }]);
}, []);
const handleZoomOut = useCallback(() => {
setZoomCommand('out');
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: verkleinert', type: 'info' }]);
}, []);
const handleZoomFit = useCallback(() => {
setZoomCommand('fit');
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]);
}, []);
// Layer reorder (drag & drop)
const handleReorderLayer = useCallback((draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => {
setLayers((prev) => {
const dragged = prev.find((l) => l.id === draggedId);
if (!dragged) return prev;
if (position === 'inside') {
// Make dragged a child of target
const updated = prev.map((l) =>
l.id === draggedId ? { ...l, parentId: targetId } : l
);
return updated;
}
// before/after: reorder at same level
const target = prev.find((l) => l.id === targetId);
if (!target) return prev;
const parentId = target.parentId;
const siblings = prev
.filter((l) => l.parentId === parentId && l.id !== draggedId)
.sort((a, b) => a.sortOrder - b.sortOrder);
const targetIdx = siblings.findIndex((l) => l.id === targetId);
const insertIdx = position === 'before' ? targetIdx : targetIdx + 1;
siblings.splice(insertIdx, 0, { ...dragged, parentId });
// Reassign sortOrder
const reordered = siblings.map((l, i) => ({ ...l, sortOrder: i }));
// Merge back: non-siblings keep original, siblings get reordered
const nonSiblings = prev.filter((l) => l.parentId !== parentId || l.id === draggedId);
const result = [...nonSiblings.filter((l) => l.id !== draggedId), ...reordered];
return result;
});
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Ebene verschoben', type: 'info' }]);
}, []);
// Add sub-layer
const handleAddSubLayer = useCallback((parentId: string) => {
setLayers((prev) => {
const parent = prev.find((l) => l.id === parentId);
if (!parent) return prev;
const newId = `layer-${Date.now()}`;
const siblings = prev.filter((l) => l.parentId === parentId);
const newLayer: CADLayer = {
id: newId, name: `Teilebene ${siblings.length + 1}`, visible: true, locked: false,
color: parent.color, lineType: 'solid', transparency: 0,
sortOrder: siblings.length, parentId,
};
if (drawingId && token) {
createLayerTyped(token, drawingId, newLayer).catch((err) => {
console.error('Failed to save sub-layer:', err);
});
}
return [...prev, newLayer];
});
}, [drawingId, token]);
// Update single element (from properties panel)
const handleUpdateElement = useCallback((updated: CADElement) => {
setElements((prev) => {
const newElements = prev.map((e) => (e.id === updated.id ? updated : e));
historyManagerRef.current.pushSnapshot({
elements: newElements, layers, blocks, groups, bgConfig,
}, 'Element geändert');
return newElements;
});
syncHistory();
collab.setElement(updated);
if (token) {
setSavedStatus('Speichert…');
updateElement(token, updated.id, updated).then(() => {
setSavedStatus('gespeichert');
}).catch((err) => {
console.error('Failed to update element:', err);
setSavedStatus('Fehler beim Speichern');
});
}
setSelectedElement(updated);
}, [layers, blocks, groups, bgConfig, syncHistory, token, collab]);
const handleBgApply = useCallback((config: BackgroundConfig, _image: HTMLImageElement | null) => {
setBgConfig(config);
setBgImportOpen(false);
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Hintergrund geladen: ${config.name} (${config.width}×${config.height}px, Maßstab: ${config.scale.toFixed(3)} px/mm)`, type: 'info' }]);
}, []);
const handleCommand = useCallback((cmd: string) => {
const upper = cmd.trim().toUpperCase();
setCommandHistory((prev) => [...prev, { prefix: '', text: cmd, type: 'command' }]);
const registry = getCommandRegistry();
if (upper === 'UNDO' || upper === 'U') {
handleUndo();
return;
}
if (upper === 'REDO') {
handleRedo();
return;
}
// Group command: create group from currently selected elements
if (upper === 'GROUP' || upper === 'GRP') {
const gm = groupManagerRef.current;
const ids = selectedElementIdsRef.current;
if (ids.length < 2) {
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe: Mindestens 2 Elemente auswählen', type: 'info' }]);
return;
}
const group = gm.createGroup(ids);
const newGroups = gm.getGroups();
setGroups(newGroups);
// Sync group to Yjs CRDT for real-time collaboration
collab.setGroup(group);
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe erstellt: ${group.name} (${ids.length} Elemente)`, type: 'info' }]);
return;
}
// Ungroup command
if (upper === 'UNG' || upper === 'UNGROUP') {
const gm = groupManagerRef.current;
const allGroups = gm.getGroups();
if (allGroups.length > 0) {
gm.ungroup(allGroups[allGroups.length - 1].id);
setGroups(gm.getGroups());
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe aufgelöst', type: 'info' }]);
}
return;
}
// Import command — trigger file dialog
if (upper === 'IMPORT' || upper === 'IMP' || upper === 'I') {
handleRibbonAction('import');
return;
}
// Export command — trigger export dialog
if (upper === 'EXPORT' || upper === 'EXP' || upper === 'EX') {
handleRibbonAction('export');
return;
}
const tool = registry.getToolId(upper);
if (tool) {
setActiveTool(tool);
setMobileLeftOpen(false);
const label = registry.getLabel(upper) ?? `Werkzeug: ${tool}`;
setCommandHistory((prev) => [...prev, { prefix: '·', text: label, type: 'info' }]);
} else {
// Check plugin commands
const pluginCmds = pluginRegistry.getCommandExtensions();
const parts = cmd.trim().split(/\s+/);
const cmdName = parts[0].toUpperCase();
const pluginCmd = pluginCmds.find((c) => c.name.toUpperCase() === cmdName);
if (pluginCmd) {
pluginCmd.execute(parts.slice(1), createPluginContext());
} else {
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Unbekannter Befehl: ${cmd}`, type: 'info' }]);
}
}
}, [handleUndo, handleRedo, handleRibbonAction, collab, createPluginContext]);
const handleKISend = useCallback(async (text: string) => {
const userMsg: KIMessage = { id: `ki-${Date.now()}`, role: 'user', content: text };
const pendingId = `ki-${Date.now() + 1}`;
const pendingMsg: KIMessage = { id: pendingId, role: 'assistant', content: '…' };
setKIMessages((prev) => [...prev, userMsg, pendingMsg]);
setKiLoading(true);
try {
// Build CAD context
const elementTypeSummary: Record<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}
onOpenSettings={() => setSettingsOpen(true)}
onOpenLeftDrawer={() => setMobileLeftOpen(true)}
onOpenRightDrawer={() => setMobileRightOpen(true)}
unit={unit}
onUnitChange={handleUnitChange}
onNavigateBack={onNavigateBack}
/>
<RibbonBar
activeTab={activeRibbonTab}
onTabChange={setActiveRibbonTab}
onAction={handleRibbonAction}
canvasBgColor={canvasBgColor}
gridColor={gridColor}
rulerEnabled={rulerEnabled}
onCanvasBgColorChange={setCanvasBgColor}
onGridColorChange={setGridColor}
onToggleRuler={() => setRulerEnabled((p) => !p)}
/>
<div className={`app-body${leftSidebarCollapsed ? ' left-collapsed' : ''}${rightSidebarCollapsed ? ' right-collapsed' : ''}`}>
<LeftSidebar
activeTool={activeTool}
onToolChange={handleToolChange}
selectedTemplate={selectedTemplate}
onTemplateSelect={handleTemplateSelect}
className={mobileLeftOpen ? 'open' : ''}
onCollapse={() => { setMobileLeftOpen(false); setLeftSidebarCollapsed(true); }}
collapsed={leftSidebarCollapsed}
onToggleCollapse={() => setLeftSidebarCollapsed((p) => !p)}
blocks={blocks}
onDragBlock={handleDragBlock}
onBlockCategoryChange={handleBlockCategoryChange}
onBlockSearch={handleBlockSearch}
onRenameBlock={handleRenameBlock}
onDuplicateBlock={handleDuplicateBlock}
onDeleteBlock={handleDeleteBlock}
onSvgImport={handleSvgImport}
onSaveGroupAsBlock={handleSaveGroupAsBlock}
onGroup={() => handleRibbonAction('group')}
onUngroup={() => handleRibbonAction('ungroup')}
leftbarWidth={leftbarWidth}
onWidthChange={setLeftbarWidth}
/>
<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}
zoomCommand={zoomCommand}
onTextEdit={handleTextEdit}
onCommandTrigger={handleCommandTrigger}
blocks={blocks}
onBlockDrop={handleBlockDrop}
onSelectionChange={handleSelectionChange}
externalSelectionIds={selectedElementIds}
selectedTemplate={selectedTemplate}
bgConfig={bgConfig}
remoteCursors={collab.cursors}
groups={groups}
unit={unit}
gridSize={gridSize}
scaleFactor={scaleFactor}
canvasBgColor={canvasBgColor}
gridColor={gridColor}
rulerEnabled={rulerEnabled}
/>
<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}
onElementsDeleted={handleElementsDeleted}
onToggleElementVisible={handleToggleElementVisible}
elements={elements}
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}
className={mobileRightOpen ? 'open' : ''}
onCollapse={() => { setMobileRightOpen(false); setRightSidebarCollapsed(true); }}
collapsed={rightSidebarCollapsed}
onToggleCollapse={() => setRightSidebarCollapsed((p) => !p)}
rightbarWidth={rightbarWidth}
onWidthChange={setRightbarWidth}
onReorder={handleReorderLayer}
onAddSubLayer={handleAddSubLayer}
onUpdateElement={handleUpdateElement}
selectedElementIds={selectedElementIds}
groups={groups}
onSelectElement={handleSelectElement}
token={token}
unit={unit}
scaleFactor={scaleFactor}
/>
</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}
unit={unit}
scaleFactor={scaleFactor}
onUnitChange={handleUnitChange}
/>
<MobileDrawers
leftOpen={mobileLeftOpen}
rightOpen={mobileRightOpen}
activeRightTab={activeDrawerTab}
onCloseLeft={() => setMobileLeftOpen(false)}
onCloseRight={() => setMobileRightOpen(false)}
onRightTabChange={setActiveDrawerTab}
/>
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
unit={unit}
gridSize={gridSize}
scaleFactor={scaleFactor}
onUnitChange={handleUnitChange}
onGridSizeChange={handleGridSizeChange}
onScaleFactorChange={handleScaleFactorChange}
/>
<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)}
/>
)}
{exportFormatOpen && (
<div className="export-format-overlay" onClick={() => setExportFormatOpen(false)}>
<div className="export-format-modal" onClick={(e) => e.stopPropagation()}>
<h3>Export als</h3>
<div className="export-format-buttons">
{(['dxf', 'svg', 'pdf', 'png', 'json'] as ExportFormat[]).map(fmt => (
<button
key={fmt}
className="export-format-btn"
onClick={() => {
setExportFormatOpen(false);
handleExport(fmt);
}}
>
{fmt.toUpperCase()}
</button>
))}
</div>
<button className="export-format-cancel" onClick={() => setExportFormatOpen(false)}>Abbrechen</button>
</div>
</div>
)}
{editingTextElement && (
<InlineTextEditor
initialText={editingTextElement.properties?.text ?? ''}
onSubmit={handleTextEditorSubmit}
onCancel={handleTextEditorCancel}
/>
)}
</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;