2026-06-26 10:50:24 +02:00
|
|
|
|
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
|
|
|
|
|
import type {
|
|
|
|
|
|
Theme, RibbonTab, ViewMode, RightPanel, DrawerTab,
|
|
|
|
|
|
CursorPos, CommandHistoryEntry, KIMessage, KISuggestion,
|
|
|
|
|
|
} from './types/ui.types';
|
2026-07-04 16:43:55 +02:00
|
|
|
|
import type { CADElement, CADLayer, BlockDefinition, ImageElement } from './types/cad.types';
|
|
|
|
|
|
import { BlockService } from './services/blockService';
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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';
|
2026-07-04 16:08:51 +02:00
|
|
|
|
import SettingsModal from './components/SettingsModal';
|
2026-07-04 16:43:55 +02:00
|
|
|
|
import InlineTextEditor from './components/InlineTextEditor';
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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';
|
2026-07-04 16:43:55 +02:00
|
|
|
|
import { loadProjectDataTyped, createElementTyped, updateElement, deleteElement as apiDeleteElement, createLayerTyped, updateLayer, deleteLayer as apiDeleteLayer, createBlockTyped, updateBlock, deleteBlock as apiDeleteBlock, aiChat, getSetting, setSetting } from './services/api';
|
2026-06-26 10:50:24 +02:00
|
|
|
|
import { useYjsBinding } from './crdt';
|
|
|
|
|
|
import { registerBuiltinPlugins, pluginRegistry } from './plugins';
|
|
|
|
|
|
import type { PluginContext } from './plugins';
|
2026-07-04 16:43:55 +02:00
|
|
|
|
import type { UnitType } from './utils/format';
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// ─── Initial State ──────────────────────────────────────────
|
|
|
|
|
|
// Data is loaded from the backend on mount — start with empty state.
|
|
|
|
|
|
const initialLayers: CADLayer[] = [];
|
|
|
|
|
|
const initialBlocks: BlockDefinition[] = [];
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
const initialCommandHistory: CommandHistoryEntry[] = [
|
2026-07-04 16:43:55 +02:00
|
|
|
|
{ prefix: '·', text: 'Bereit', type: 'info' },
|
2026-06-26 10:50:24 +02:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-26 18:49:16 +02:00
|
|
|
|
onNavigateBack: () => void;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 18:49:16 +02:00
|
|
|
|
const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack }) => {
|
2026-06-26 10:50:24 +02:00
|
|
|
|
// 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,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
token: token || undefined,
|
2026-06-26 10:50:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
// Right sidebar
|
|
|
|
|
|
const [activeRightPanel, setActiveRightPanel] = useState<RightPanel>('tool');
|
|
|
|
|
|
const [selectedElement, setSelectedElement] = useState<CADElement | null>(null);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
const [layers, setLayers] = useState<CADLayer[]>(initialLayers);
|
2026-06-27 14:12:37 +02:00
|
|
|
|
const [activeLayerId, setActiveLayerId] = useState<string>('layer-0');
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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' }]),
|
|
|
|
|
|
}), []);
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
useEffect(() => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
pluginRegistry.setContext(createPluginContext());
|
2026-06-26 10:50:24 +02:00
|
|
|
|
registerBuiltinPlugins();
|
|
|
|
|
|
pluginRegistry.initDefaults();
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [createPluginContext]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// Status bar – only count self as online when actually connected
|
|
|
|
|
|
const onlineCount = collab.status === 'connected' ? collab.cursors.length : 0;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
// Mobile drawers
|
|
|
|
|
|
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
|
|
|
|
|
|
const [mobileRightOpen, setMobileRightOpen] = useState(false);
|
2026-07-04 16:08:51 +02:00
|
|
|
|
// Desktop sidebar collapse
|
|
|
|
|
|
const [leftSidebarCollapsed, setLeftSidebarCollapsed] = useState(false);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
const [leftbarWidth, setLeftbarWidth] = useState(200);
|
|
|
|
|
|
const [rightbarWidth, setRightbarWidth] = useState(300);
|
2026-07-04 16:08:51 +02:00
|
|
|
|
const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(false);
|
|
|
|
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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());
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// Cleanup BackgroundService on unmount
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
bgServiceRef.current.dispose();
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
// History panel
|
|
|
|
|
|
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
const seatingServiceRef = useRef<SeatingService>(new SeatingService());
|
2026-06-26 10:50:24 +02:00
|
|
|
|
const seatCount = useMemo(() => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
return seatingServiceRef.current.countSeats(elements).total;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}, [elements]);
|
|
|
|
|
|
|
|
|
|
|
|
// Groups
|
|
|
|
|
|
const groupManagerRef = React.useRef(new GroupManager());
|
|
|
|
|
|
const [groups, setGroups] = useState<ElementGroup[]>([]);
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-06-26 18:49:16 +02:00
|
|
|
|
// Clipboard (for copy/paste)
|
|
|
|
|
|
const clipboardRef = React.useRef<CADElement[] | null>(null);
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
// ─── 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();
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rückgängig: letzte Aktion', type: 'info' }]);
|
|
|
|
|
|
}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [restoreSnapshot, syncHistory, collab]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
const handleRedo = useCallback(() => {
|
|
|
|
|
|
const snap = historyManagerRef.current.redo();
|
|
|
|
|
|
if (snap) {
|
|
|
|
|
|
restoreSnapshot(snap);
|
|
|
|
|
|
syncHistory();
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Wiederherstellen: letzte Aktion', type: 'info' }]);
|
|
|
|
|
|
}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [restoreSnapshot, syncHistory, collab]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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');
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setSavedStatus('gespeichert');
|
|
|
|
|
|
setDataLoaded(true);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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();
|
2026-06-26 10:50:24 +02:00
|
|
|
|
// Push initial data to Yjs CRDT after load
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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,
|
|
|
|
|
|
});
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// Ignore race condition errors from React StrictMode double-render
|
2026-07-04 16:08:51 +02:00
|
|
|
|
if (cancelled) return;
|
|
|
|
|
|
if (err instanceof Error && err.message.includes('Superseded')) return;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
console.error('Failed to load project:', err);
|
|
|
|
|
|
setSavedStatus('Fehler beim Laden');
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
return () => { cancelled = true; };
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [projectId, token]);
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-06-26 20:31:50 +02:00
|
|
|
|
// 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)
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// Merge: update local elements with remote version when properties differ, add remote-only elements
|
2026-06-26 20:31:50 +02:00
|
|
|
|
const localIds = new Set(elements.map(e => e.id));
|
|
|
|
|
|
const remoteIds = new Set(collab.elements.map(e => e.id));
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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) {
|
2026-06-26 20:31:50 +02:00
|
|
|
|
setElements(merged);
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// Merge layers: local-first, update changed from remote, append remote-only
|
2026-07-04 16:08:51 +02:00
|
|
|
|
if (collab.layers.length > 0) {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-04 16:08:51 +02:00
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// Merge blocks: local-first, update changed from remote, append remote-only
|
2026-07-04 16:08:51 +02:00
|
|
|
|
if (collab.blocks.length > 0) {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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);
|
2026-07-04 16:08:51 +02:00
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [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]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
const handleElementCreated = useCallback((el: CADElement) => {
|
2026-06-26 20:31:50 +02:00
|
|
|
|
const elWithLayer = el.layerId ? el : { ...el, layerId: activeLayerId };
|
2026-06-26 18:57:09 +02:00
|
|
|
|
setElements((prev) => {
|
2026-06-26 20:31:50 +02:00
|
|
|
|
const newElements = [...prev, elWithLayer];
|
2026-06-26 18:57:09 +02:00
|
|
|
|
historyManagerRef.current.pushSnapshot({
|
|
|
|
|
|
elements: newElements, layers, blocks, groups, bgConfig,
|
|
|
|
|
|
}, 'Element erstellt');
|
|
|
|
|
|
return newElements;
|
|
|
|
|
|
});
|
2026-06-26 10:50:24 +02:00
|
|
|
|
syncHistory();
|
|
|
|
|
|
// Push to Yjs CRDT for real-time sync
|
2026-06-26 20:31:50 +02:00
|
|
|
|
collab.setElement(elWithLayer);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
// Save to backend
|
|
|
|
|
|
if (drawingId && token) {
|
|
|
|
|
|
setSavedStatus('Speichert…');
|
2026-06-26 20:31:50 +02:00
|
|
|
|
createElementTyped(token, drawingId, elWithLayer).then(() => {
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setSavedStatus('gespeichert');
|
|
|
|
|
|
}).catch((err) => {
|
|
|
|
|
|
console.error('Failed to save element:', err);
|
|
|
|
|
|
setSavedStatus('Fehler beim Speichern');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-26 20:31:50 +02:00
|
|
|
|
}, [layers, blocks, groups, bgConfig, syncHistory, drawingId, token, collab, activeLayerId]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
2026-06-27 23:59:12 +02:00
|
|
|
|
const handleToggleElementVisible = useCallback((id: string) => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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]);
|
2026-06-27 23:59:12 +02:00
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
const handleElementsDeleted = useCallback((ids: string[]) => {
|
2026-06-26 18:57:09 +02:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [layers, blocks, groups, bgConfig, syncHistory, token, collab]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
const handleElementsModified = useCallback((modified: CADElement[]) => {
|
2026-06-26 18:57:09 +02:00
|
|
|
|
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;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-26 18:57:09 +02:00
|
|
|
|
}, [layers, blocks, groups, bgConfig, syncHistory, token, collab]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// Inline text editor state
|
|
|
|
|
|
const [editingTextElement, setEditingTextElement] = useState<CADElement | null>(null);
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
const handleTextEdit = useCallback((el: CADElement) => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
setEditingTextElement(el);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const handleTextEditorSubmit = useCallback((text: string) => {
|
|
|
|
|
|
if (editingTextElement && text.trim() !== '') {
|
|
|
|
|
|
const updatedEl = { ...editingTextElement, properties: { ...editingTextElement.properties, text } };
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setElements((prev) => prev.map((e) =>
|
2026-07-04 16:43:55 +02:00
|
|
|
|
e.id === editingTextElement.id ? updatedEl : e
|
2026-06-26 10:50:24 +02:00
|
|
|
|
));
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
setEditingTextElement(null);
|
|
|
|
|
|
}, [editingTextElement, collab, token]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleTextEditorCancel = useCallback(() => {
|
|
|
|
|
|
setEditingTextElement(null);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const handleCommandTrigger = useCallback((msg: string) => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [collab]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
// 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' }]);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// Persist rename to backend
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
updateBlock(token, id, { name }).catch((err) => {
|
|
|
|
|
|
console.error('Failed to save block rename:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [token]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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()}` })),
|
|
|
|
|
|
};
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// Persist duplicate to backend
|
|
|
|
|
|
if (drawingId && token) {
|
|
|
|
|
|
createBlockTyped(token, drawingId, copy).catch((err) => {
|
|
|
|
|
|
console.error('Failed to save duplicated block:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
return [...prev, copy];
|
|
|
|
|
|
});
|
|
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block dupliziert', type: 'info' }]);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [drawingId, token]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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]);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: `SVG importiert: ${name}`, type: 'info' }]);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [collab, drawingId, token]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
const handleSaveGroupAsBlock = useCallback((name: string) => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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)}` })),
|
|
|
|
|
|
};
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
return [...prev, block];
|
|
|
|
|
|
});
|
|
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block erstellt: ${name} (${selectedEls.length} Elemente)`, type: 'info' }]);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [selectedElement, collab, drawingId, token]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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' }]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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' }]);
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
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' }]);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [blocks, activeLayerId, collab, drawingId, token]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
const handleSelectionChange = useCallback((selectedIds: string[]) => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
setSelectedElementIds(selectedIds);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
if (selectedIds.length === 1) {
|
|
|
|
|
|
const el = elements.find(e => e.id === selectedIds[0]);
|
|
|
|
|
|
setSelectedElement(el ?? null);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
} else if (selectedIds.length === 0) {
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setSelectedElement(null);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
} 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);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
}, [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]);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
if (result.layers && result.layers.length > 0) {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
if (result.blocks && result.blocks.length > 0) {
|
|
|
|
|
|
setBlocks((prev) => [...prev, ...result.blocks!]);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
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' }]);
|
|
|
|
|
|
}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [drawingId, token, collab, layers]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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' }]);
|
2026-06-26 18:49:16 +02:00
|
|
|
|
|
|
|
|
|
|
// ─── File actions ───
|
|
|
|
|
|
if (action === 'new') {
|
|
|
|
|
|
onNavigateBack();
|
|
|
|
|
|
return;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
2026-06-26 18:49:16 +02:00
|
|
|
|
if (action === 'open') {
|
|
|
|
|
|
onNavigateBack();
|
|
|
|
|
|
return;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
2026-06-26 18:49:16 +02:00
|
|
|
|
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;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
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();
|
2026-06-26 18:49:16 +02:00
|
|
|
|
return;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
if (action === 'export') {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
setExportFormatOpen(true);
|
2026-06-26 18:49:16 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Edit actions ───
|
|
|
|
|
|
if (action === 'undo') { handleUndo(); return; }
|
|
|
|
|
|
if (action === 'redo') { handleRedo(); return; }
|
|
|
|
|
|
if (action === 'copy') {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
const ids = selectedElementIdsRef.current;
|
|
|
|
|
|
const selected = ids.length > 0 ? elements.filter(e => ids.includes(e.id)) : [];
|
2026-06-26 18:49:16 +02:00
|
|
|
|
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;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
}
|
2026-06-26 18:49:16 +02:00
|
|
|
|
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}`,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
x: (el.x ?? 0) + offset,
|
|
|
|
|
|
y: (el.y ?? 0) + offset,
|
2026-06-26 18:49:16 +02:00
|
|
|
|
}));
|
|
|
|
|
|
setElements((prev) => [...prev, ...pasted]);
|
|
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: `${pasted.length} Element(e) eingefügt`, type: 'info' }]);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-26 18:49:16 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zwischenablage leer', type: 'info' }]);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// ─── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 18:49:16 +02:00
|
|
|
|
// ─── 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 = () => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
const imgEl: ImageElement = {
|
2026-06-26 18:49:16 +02:00
|
|
|
|
id: `el-${Date.now()}`,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
type: 'image',
|
2026-06-26 18:49:16 +02:00
|
|
|
|
layerId: activeLayerId,
|
|
|
|
|
|
x: 0, y: 0,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
width: 200,
|
|
|
|
|
|
height: 200,
|
|
|
|
|
|
properties: { src: reader.result as string },
|
|
|
|
|
|
};
|
2026-06-26 18:49:16 +02:00
|
|
|
|
setElements((prev) => [...prev, imgEl]);
|
|
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Bild eingefügt', type: 'info' }]);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
// 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');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-26 18:49:16 +02:00
|
|
|
|
};
|
|
|
|
|
|
reader.readAsDataURL(input.files[0]);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
input.click();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Format actions ───
|
|
|
|
|
|
if (action.startsWith('format-')) {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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' }]);
|
2026-06-26 18:49:16 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── View actions ───
|
|
|
|
|
|
if (action === 'zoom-fit') {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
setZoomCommand('fit');
|
2026-06-26 18:49:16 +02:00
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (action === 'zoom-100') {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
setZoomCommand('100');
|
2026-06-26 18:49:16 +02:00
|
|
|
|
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; }
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [handleUndo, handleRedo, handleElementsModified, handleImport, handleExport, onNavigateBack, drawingId, token, elements, activeLayerId, gridEnabled, collab, selectedElement]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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), []);
|
2026-07-04 16:43:55 +02:00
|
|
|
|
|
|
|
|
|
|
const handleSelectElement = useCallback((id: string) => {
|
|
|
|
|
|
setSelectedElementIds([id]);
|
|
|
|
|
|
const el = elements.find(e => e.id === id);
|
|
|
|
|
|
setSelectedElement(el ?? null);
|
|
|
|
|
|
}, [elements]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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) => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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) => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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 };
|
2026-07-04 16:43:55 +02:00
|
|
|
|
collab.setLayer(copy);
|
|
|
|
|
|
if (drawingId && token) {
|
|
|
|
|
|
createLayerTyped(token, drawingId, copy).catch((err) => {
|
|
|
|
|
|
console.error('Failed to save duplicated layer:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
return [...prev, copy];
|
|
|
|
|
|
});
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [drawingId, token, collab]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
const handleToggleLock = useCallback((id: string) => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
const handleUpdateLayerColor = useCallback((id: string, color: string) => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
const handleUpdateLayerLineType = useCallback((id: string, lineType: 'solid' | 'dashed' | 'dotted') => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
const handleUpdateLayerTransparency = useCallback((id: string, transparency: number) => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
const handleZoomIn = useCallback(() => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
setZoomCommand('in');
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: vergrößert', type: 'info' }]);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
const handleZoomOut = useCallback(() => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
setZoomCommand('out');
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: verkleinert', type: 'info' }]);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
const handleZoomFit = useCallback(() => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
setZoomCommand('fit');
|
2026-06-26 10:50:24 +02:00
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-07-04 16:08:51 +02:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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;
|
2026-07-04 16:43:55 +02:00
|
|
|
|
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' }]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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) {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
pluginCmd.execute(parts.slice(1), createPluginContext());
|
2026-06-26 10:50:24 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Unbekannter Befehl: ${cmd}`, type: 'info' }]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
}, [handleUndo, handleRedo, handleRibbonAction, collab, createPluginContext]);
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-07-04 16:43:55 +02:00
|
|
|
|
const activeLayerName = layers.find((l) => l.id === activeLayerId)?.name ?? '—';
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
// ─── Render ─────────────────────────────────────────────
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={`app ${theme}`} data-theme={theme}>
|
|
|
|
|
|
<Topbar
|
|
|
|
|
|
projectName={projectName}
|
|
|
|
|
|
savedStatus={savedStatus}
|
|
|
|
|
|
onUndo={handleUndo}
|
|
|
|
|
|
onRedo={handleRedo}
|
|
|
|
|
|
onThemeToggle={handleThemeToggle}
|
|
|
|
|
|
theme={theme}
|
2026-07-04 16:08:51 +02:00
|
|
|
|
onOpenSettings={() => setSettingsOpen(true)}
|
|
|
|
|
|
onOpenLeftDrawer={() => setMobileLeftOpen(true)}
|
|
|
|
|
|
onOpenRightDrawer={() => setMobileRightOpen(true)}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
unit={unit}
|
|
|
|
|
|
onUnitChange={handleUnitChange}
|
|
|
|
|
|
onNavigateBack={onNavigateBack}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
/>
|
|
|
|
|
|
<RibbonBar
|
|
|
|
|
|
activeTab={activeRibbonTab}
|
|
|
|
|
|
onTabChange={setActiveRibbonTab}
|
|
|
|
|
|
onAction={handleRibbonAction}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
canvasBgColor={canvasBgColor}
|
|
|
|
|
|
gridColor={gridColor}
|
|
|
|
|
|
rulerEnabled={rulerEnabled}
|
|
|
|
|
|
onCanvasBgColorChange={setCanvasBgColor}
|
|
|
|
|
|
onGridColorChange={setGridColor}
|
|
|
|
|
|
onToggleRuler={() => setRulerEnabled((p) => !p)}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
/>
|
2026-07-04 16:08:51 +02:00
|
|
|
|
<div className={`app-body${leftSidebarCollapsed ? ' left-collapsed' : ''}${rightSidebarCollapsed ? ' right-collapsed' : ''}`}>
|
2026-06-26 10:50:24 +02:00
|
|
|
|
<LeftSidebar
|
|
|
|
|
|
activeTool={activeTool}
|
|
|
|
|
|
onToolChange={handleToolChange}
|
|
|
|
|
|
selectedTemplate={selectedTemplate}
|
|
|
|
|
|
onTemplateSelect={handleTemplateSelect}
|
2026-07-04 16:08:51 +02:00
|
|
|
|
className={mobileLeftOpen ? 'open' : ''}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
onCollapse={() => { setMobileLeftOpen(false); setLeftSidebarCollapsed(true); }}
|
2026-07-04 16:08:51 +02:00
|
|
|
|
collapsed={leftSidebarCollapsed}
|
|
|
|
|
|
onToggleCollapse={() => setLeftSidebarCollapsed((p) => !p)}
|
|
|
|
|
|
blocks={blocks}
|
|
|
|
|
|
onDragBlock={handleDragBlock}
|
|
|
|
|
|
onBlockCategoryChange={handleBlockCategoryChange}
|
|
|
|
|
|
onBlockSearch={handleBlockSearch}
|
|
|
|
|
|
onRenameBlock={handleRenameBlock}
|
|
|
|
|
|
onDuplicateBlock={handleDuplicateBlock}
|
|
|
|
|
|
onDeleteBlock={handleDeleteBlock}
|
|
|
|
|
|
onSvgImport={handleSvgImport}
|
|
|
|
|
|
onSaveGroupAsBlock={handleSaveGroupAsBlock}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
onGroup={() => handleRibbonAction('group')}
|
|
|
|
|
|
onUngroup={() => handleRibbonAction('ungroup')}
|
|
|
|
|
|
leftbarWidth={leftbarWidth}
|
|
|
|
|
|
onWidthChange={setLeftbarWidth}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
/>
|
|
|
|
|
|
<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}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
zoomCommand={zoomCommand}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
onTextEdit={handleTextEdit}
|
|
|
|
|
|
onCommandTrigger={handleCommandTrigger}
|
|
|
|
|
|
blocks={blocks}
|
|
|
|
|
|
onBlockDrop={handleBlockDrop}
|
|
|
|
|
|
onSelectionChange={handleSelectionChange}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
externalSelectionIds={selectedElementIds}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
selectedTemplate={selectedTemplate}
|
|
|
|
|
|
bgConfig={bgConfig}
|
|
|
|
|
|
remoteCursors={collab.cursors}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
groups={groups}
|
|
|
|
|
|
unit={unit}
|
|
|
|
|
|
gridSize={gridSize}
|
|
|
|
|
|
scaleFactor={scaleFactor}
|
|
|
|
|
|
canvasBgColor={canvasBgColor}
|
|
|
|
|
|
gridColor={gridColor}
|
|
|
|
|
|
rulerEnabled={rulerEnabled}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
/>
|
|
|
|
|
|
<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}
|
2026-06-27 23:59:12 +02:00
|
|
|
|
onElementsDeleted={handleElementsDeleted}
|
|
|
|
|
|
onToggleElementVisible={handleToggleElementVisible}
|
2026-06-27 14:27:34 +02:00
|
|
|
|
elements={elements}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
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}
|
2026-07-04 16:08:51 +02:00
|
|
|
|
className={mobileRightOpen ? 'open' : ''}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
onCollapse={() => { setMobileRightOpen(false); setRightSidebarCollapsed(true); }}
|
2026-07-04 16:08:51 +02:00
|
|
|
|
collapsed={rightSidebarCollapsed}
|
|
|
|
|
|
onToggleCollapse={() => setRightSidebarCollapsed((p) => !p)}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
rightbarWidth={rightbarWidth}
|
|
|
|
|
|
onWidthChange={setRightbarWidth}
|
2026-07-04 16:08:51 +02:00
|
|
|
|
onReorder={handleReorderLayer}
|
|
|
|
|
|
onAddSubLayer={handleAddSubLayer}
|
|
|
|
|
|
onUpdateElement={handleUpdateElement}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
selectedElementIds={selectedElementIds}
|
|
|
|
|
|
groups={groups}
|
|
|
|
|
|
onSelectElement={handleSelectElement}
|
|
|
|
|
|
token={token}
|
|
|
|
|
|
unit={unit}
|
|
|
|
|
|
scaleFactor={scaleFactor}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
/>
|
|
|
|
|
|
</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}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
unit={unit}
|
|
|
|
|
|
scaleFactor={scaleFactor}
|
|
|
|
|
|
onUnitChange={handleUnitChange}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
/>
|
|
|
|
|
|
<MobileDrawers
|
|
|
|
|
|
leftOpen={mobileLeftOpen}
|
|
|
|
|
|
rightOpen={mobileRightOpen}
|
|
|
|
|
|
activeRightTab={activeDrawerTab}
|
|
|
|
|
|
onCloseLeft={() => setMobileLeftOpen(false)}
|
|
|
|
|
|
onCloseRight={() => setMobileRightOpen(false)}
|
|
|
|
|
|
onRightTabChange={setActiveDrawerTab}
|
|
|
|
|
|
/>
|
2026-07-04 16:43:55 +02:00
|
|
|
|
<SettingsModal
|
|
|
|
|
|
open={settingsOpen}
|
|
|
|
|
|
onClose={() => setSettingsOpen(false)}
|
|
|
|
|
|
unit={unit}
|
|
|
|
|
|
gridSize={gridSize}
|
|
|
|
|
|
scaleFactor={scaleFactor}
|
|
|
|
|
|
onUnitChange={handleUnitChange}
|
|
|
|
|
|
onGridSizeChange={handleGridSizeChange}
|
|
|
|
|
|
onScaleFactorChange={handleScaleFactorChange}
|
|
|
|
|
|
/>
|
2026-06-26 10:50:24 +02:00
|
|
|
|
<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)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-07-04 16:43:55 +02:00
|
|
|
|
{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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-06-26 10:50:24 +02:00
|
|
|
|
</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
|
2026-06-26 18:49:16 +02:00
|
|
|
|
return <CADEditor projectId={openedProjectId} token={token} onNavigateBack={() => setOpenedProjectId(null)} />;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default App;
|