feat: initial commit web-cad-neu with docker-compose, frontend and backend

This commit is contained in:
2026-06-26 10:50:24 +02:00
commit 4ec76fe406
102 changed files with 25722 additions and 0 deletions
@@ -0,0 +1,267 @@
import React, { useState, useRef, useCallback } from 'react';
import { BackgroundService, DEFAULT_BACKGROUND, type BackgroundConfig } from '../services/backgroundService';
interface BackgroundImportProps {
open: boolean;
onClose: () => void;
onApply: (config: BackgroundConfig, image: HTMLImageElement | null) => void;
backgroundService: BackgroundService;
}
const BackgroundImport: React.FC<BackgroundImportProps> = ({ open, onClose, onApply, backgroundService }) => {
const [config, setConfig] = useState<BackgroundConfig>({ ...DEFAULT_BACKGROUND });
const [previewUrl, setPreviewUrl] = useState<string>('');
const [calibrationMode, setCalibrationMode] = useState(false);
const [calibPoint1, setCalibPoint1] = useState<{ x: number; y: number } | null>(null);
const [calibPoint2, setCalibPoint2] = useState<{ x: number; y: number } | null>(null);
const [realDistance, setRealDistance] = useState('');
const [unit, setUnit] = useState('m');
const [error, setError] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError('');
try {
const cfg = await backgroundService.loadFromFile(file);
setConfig(cfg);
setPreviewUrl(cfg.src);
} catch (err) {
setError('Fehler beim Laden des Bildes: ' + (err as Error).message);
}
}, [backgroundService]);
const handlePreviewClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!calibrationMode || !previewCanvasRef.current) return;
const rect = previewCanvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (!calibPoint1) {
setCalibPoint1({ x, y });
} else if (!calibPoint2) {
setCalibPoint2({ x, y });
}
}, [calibrationMode, calibPoint1, calibPoint2]);
const handleCalibrate = useCallback(() => {
if (!calibPoint1 || !calibPoint2 || !realDistance) return;
const dx = calibPoint2.x - calibPoint1.x;
const dy = calibPoint2.y - calibPoint1.y;
const pixelDist = Math.sqrt(dx * dx + dy * dy);
const realDist = parseFloat(realDistance);
if (realDist <= 0) {
setError('Bitte eine gültige Referenzstrecke eingeben.');
return;
}
const result = backgroundService.calibrateScale(pixelDist, realDist, unit);
setConfig(prev => ({ ...prev, scale: result.scale }));
setCalibrationMode(false);
setCalibPoint1(null);
setCalibPoint2(null);
setRealDistance('');
}, [calibPoint1, calibPoint2, realDistance, unit, backgroundService]);
const handleApply = useCallback(() => {
const cfg = backgroundService.getConfig();
onApply(cfg, backgroundService.getImage());
onClose();
}, [backgroundService, onApply, onClose]);
const updateConfig = useCallback((partial: Partial<BackgroundConfig>) => {
backgroundService.updateConfig(partial);
setConfig(backgroundService.getConfig());
}, [backgroundService]);
if (!open) return null;
return (
<div className="modal-overlay" style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 1000,
}}>
<div className="modal-dialog" style={{
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
padding: '24px', maxWidth: '600px', width: '90%', maxHeight: '90vh',
overflow: 'auto', color: 'var(--color-text, #e0e0e0)',
border: '1px solid var(--color-border, #333)',
}}>
<h2 style={{ marginTop: 0, marginBottom: '16px' }}>Hintergrund importieren</h2>
{error && (
<div style={{ color: '#ff6b6b', marginBottom: '12px', fontSize: '14px' }}>{error}</div>
)}
{/* File Upload */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px' }}>Bilddatei (PNG, JPG, SVG)</label>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,application/pdf"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<button
onClick={() => fileInputRef.current?.click()}
style={{
padding: '8px 16px', borderRadius: '4px',
border: '1px solid var(--color-border, #444)',
background: 'var(--color-bg-secondary, #2a2a3a)',
color: 'var(--color-text, #e0e0e0)', cursor: 'pointer',
}}
>
Datei wählen
</button>
{config.name && (
<span style={{ marginLeft: '12px', fontSize: '13px' }}>{config.name} ({config.width}×{config.height}px)</span>
)}
</div>
{/* Preview */}
{previewUrl && (
<div style={{ marginBottom: '16px' }}>
<canvas
ref={previewCanvasRef}
onClick={handlePreviewClick}
style={{
maxWidth: '100%', maxHeight: '200px',
border: '1px solid var(--color-border, #333)',
cursor: calibrationMode ? 'crosshair' : 'default',
display: 'block',
}}
width={config.width || 400}
height={config.height || 300}
/>
</div>
)}
{/* Calibration */}
{previewUrl && (
<div style={{ marginBottom: '16px', padding: '12px', border: '1px solid var(--color-border, #333)', borderRadius: '4px' }}>
<div style={{ fontSize: '14px', marginBottom: '8px', fontWeight: 600 }}>Maßstabs-Kalibrierung</div>
{!calibrationMode ? (
<button
onClick={() => setCalibrationMode(true)}
style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer',
border: '1px solid var(--color-border, #444)', borderRadius: '4px',
background: 'var(--color-bg-secondary, #2a2a3a)', color: 'var(--color-text, #e0e0e0)' }}
>
Kalibrierung starten
</button>
) : (
<div>
<p style={{ fontSize: '13px', margin: '0 0 8px' }}>
Klicken Sie auf zwei Punkte mit bekanntem Abstand im Bild.
</p>
{calibPoint1 && !calibPoint2 && <p style={{ fontSize: '13px', color: '#8be9fd' }}>Erster Punkt gesetzt. Bitte zweiten Punkt klicken.</p>}
{calibPoint1 && calibPoint2 && (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="number"
placeholder="Referenzstrecke"
value={realDistance}
onChange={(e) => setRealDistance(e.target.value)}
style={{ width: '120px', padding: '4px 8px', fontSize: '13px' }}
/>
<select value={unit} onChange={(e) => setUnit(e.target.value)} style={{ padding: '4px 8px', fontSize: '13px' }}>
<option value="m">m</option>
<option value="cm">cm</option>
<option value="mm">mm</option>
</select>
<button onClick={handleCalibrate} style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer' }}>Übernehmen</button>
<button onClick={() => { setCalibPoint1(null); setCalibPoint2(null); }} style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer' }}>Zurücksetzen</button>
</div>
)}
</div>
)}
{config.scale !== 1 && (
<p style={{ fontSize: '12px', marginTop: '8px', color: '#50fa7b' }}>
Aktueller Maßstab: {config.scale.toFixed(2)} px/mm
</p>
)}
</div>
)}
{/* Controls */}
{previewUrl && (
<div style={{ marginBottom: '16px' }}>
{/* Opacity */}
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Deckkraft: {Math.round(config.opacity * 100)}%</label>
<input
type="range" min="0" max="1" step="0.05"
value={config.opacity}
onChange={(e) => updateConfig({ opacity: parseFloat(e.target.value) })}
style={{ width: '100%', marginBottom: '12px' }}
/>
{/* Position */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '8px' }}>
<label style={{ fontSize: '13px' }}>X-Offset:
<input type="number" value={config.offsetX}
onChange={(e) => updateConfig({ offsetX: parseFloat(e.target.value) || 0 })}
style={{ width: '80px', marginLeft: '6px', padding: '4px' }} />
</label>
<label style={{ fontSize: '13px' }}>Y-Offset:
<input type="number" value={config.offsetY}
onChange={(e) => updateConfig({ offsetY: parseFloat(e.target.value) || 0 })}
style={{ width: '80px', marginLeft: '6px', padding: '4px' }} />
</label>
</div>
{/* Rotation */}
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Rotation: {config.rotation}°</label>
<input
type="range" min="-180" max="180" step="1"
value={config.rotation}
onChange={(e) => updateConfig({ rotation: parseFloat(e.target.value) })}
style={{ width: '100%', marginBottom: '12px' }}
/>
{/* Scale */}
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Skalierung: {config.scale.toFixed(3)}</label>
<input
type="number" step="0.01" value={config.scale}
onChange={(e) => updateConfig({ scale: parseFloat(e.target.value) || 1 })}
style={{ width: '120px', padding: '4px' }}
/>
{/* Visibility */}
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '12px', fontSize: '13px' }}>
<input
type="checkbox" checked={config.visible}
onChange={(e) => updateConfig({ visible: e.target.checked })}
/>
Sichtbar
</label>
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={onClose}
style={{ padding: '8px 16px', cursor: 'pointer', borderRadius: '4px',
border: '1px solid var(--color-border, #444)', background: 'transparent',
color: 'var(--color-text, #e0e0e0)' }}
>
Abbrechen
</button>
<button
onClick={handleApply}
disabled={!previewUrl}
style={{ padding: '8px 16px', cursor: previewUrl ? 'pointer' : 'not-allowed', borderRadius: '4px',
border: 'none', background: previewUrl ? '#50fa7b' : '#555',
color: previewUrl ? '#1e1e2e' : '#999', fontWeight: 600 }}
>
Anwenden
</button>
</div>
</div>
</div>
);
};
export default BackgroundImport;
+141
View File
@@ -0,0 +1,141 @@
import React, { useState, useRef } from 'react';
import type { BlockLibraryProps } from '../types/ui.types';
import type { BlockDefinition } from '../types/cad.types';
const categories = ['Alle', 'Bestuhlung', 'Tische', 'Bühne', 'Architektur', 'Custom'];
const BlockLibrary: React.FC<BlockLibraryProps> = ({ blocks, category, onCategoryChange, onSearch, onDragBlock, onRenameBlock, onDuplicateBlock, onDeleteBlock, onSvgImport, onSaveGroupAsBlock }) => {
const [searchQuery, setSearchQuery] = useState('');
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set(['Bestuhlung']));
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const q = e.target.value;
setSearchQuery(q);
onSearch(q);
};
const filteredBlocks = blocks.filter(b => {
const catMatch = category === 'Alle' || b.category === category;
const q = searchQuery.toLowerCase().trim();
const searchMatch = !q || b.name.toLowerCase().includes(q) || b.description.toLowerCase().includes(q);
return catMatch && searchMatch;
});
// Group by category
const grouped: Record<string, BlockDefinition[]> = {};
for (const b of filteredBlocks) {
if (!grouped[b.category]) grouped[b.category] = [];
grouped[b.category].push(b);
}
const toggleCat = (cat: string) => {
setExpandedCats((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
};
const handleDragStart = (e: React.DragEvent, blockId: string) => {
e.dataTransfer.setData('text/block-id', blockId);
e.dataTransfer.effectAllowed = 'copy';
onDragBlock(blockId);
};
const handleSvgImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const svgContent = ev.target?.result as string;
if (onSvgImport) {
onSvgImport(svgContent, file.name.replace(/\.svg$/i, ''), 'Custom');
} else {
window.dispatchEvent(new CustomEvent('block-svg-import', { detail: { svg: svgContent, name: file.name.replace(/\.svg$/i, ''), category: 'Custom' } }));
}
};
reader.readAsText(file);
e.target.value = '';
};
return (
<>
<div className="lib-search">
<span className="lib-search-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</span>
<input type="text" placeholder="Block suchen..." aria-label="Bibliothek durchsuchen" value={searchQuery} onChange={handleSearch} />
</div>
<div className="lib-categories">
{categories.map((cat) => (
<button key={cat} className={`lib-cat${category === cat ? ' active' : ''}`} onClick={() => onCategoryChange(cat)}>{cat}</button>
))}
</div>
<div className="lib-import">
<button className="lib-import-btn" title="SVG importieren" onClick={() => fileInputRef.current?.click()}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span>SVG importieren</span>
</button>
{onSaveGroupAsBlock && (
<button className="lib-import-btn" title="Auswahl als Block speichern" onClick={() => { const name = window.prompt('Block-Name:', ''); if (name) onSaveGroupAsBlock(name); }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
<span>Als Block speichern</span>
</button>
)}
<input ref={fileInputRef} type="file" accept=".svg,image/svg+xml" style={{ display: 'none' }} onChange={handleSvgImport} />
</div>
<div className="tree tree-library" role="tree" aria-label="Bibliothek-Struktur">
{Object.entries(grouped).map(([cat, catBlocks]) => (
<div key={cat} className="tree-node">
<div className="tree-item tree-item-folder" onClick={() => toggleCat(cat)}>
<span className="tree-toggle">{expandedCats.has(cat) ? '▾' : '▸'}</span>
<span className="tree-icon">📁</span>
<span className="tree-label">{cat}</span>
<span className="tree-count">{catBlocks.length}</span>
</div>
{expandedCats.has(cat) && (
<div className="tree-children">
{catBlocks.map((block) => (
<div
key={block.id}
className="tree-item tree-item-leaf draggable"
draggable
onDragStart={(e) => handleDragStart(e, block.id)}
title={block.description}
>
<span className="tree-icon">📦</span>
<span className="tree-label">{block.name}</span>
<span className="tree-actions" onClick={(e) => e.stopPropagation()}>
{onRenameBlock && (
<button className="layer-action-btn" title="Umbenennen" onClick={() => { const name = window.prompt('Neuer Name:', block.name); if (name) onRenameBlock(block.id, name); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
)}
{onDuplicateBlock && (
<button className="layer-action-btn" title="Duplizieren" onClick={() => onDuplicateBlock(block.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
)}
{onDeleteBlock && (
<button className="layer-action-btn" title="Löschen" onClick={() => onDeleteBlock(block.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
)}
</span>
</div>
))}
</div>
)}
</div>
))}
{filteredBlocks.length === 0 && (
<div className="lib-empty">Keine Blöcke gefunden</div>
)}
</div>
</>
);
};
export default BlockLibrary;
+344
View File
@@ -0,0 +1,344 @@
import React, { useRef, useEffect, useState } from 'react';
import type { CanvasAreaProps, ViewMode } from '../types/ui.types';
import type { CADElement } from '../types/cad.types';
import type { ToolType } from '../types/cad.types';
import type { UserCursor } from '../crdt';
import { RenderEngine } from '../canvas/RenderEngine';
import { ZoomPanController } from '../canvas/ZoomPanController';
import { InteractionEngine } from '../interaction';
import { SnapEngine } from '../canvas/SnapEngine';
import { SelectionEngine } from '../canvas/SelectionEngine';
import { SpatialIndex } from '../canvas/SpatialIndex';
import { LayerManager } from '../canvas/LayerManager';
const viewTabs: Array<{ mode: ViewMode; label: string }> = [
{ mode: '2d', label: '2D' },
{ mode: 'iso', label: 'ISO' },
{ mode: 'front', label: 'Front' },
{ mode: 'top', label: 'Top' },
];
const CanvasArea: React.FC<CanvasAreaProps> = ({
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
polarEnabled,
activeTool, elements, layers, onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged,
onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit, onTextEdit, onCommandTrigger, blocks, onBlockDrop, onSelectionChange, selectedTemplate, bgConfig, remoteCursors,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const zoomPanRef = useRef<ZoomPanController | null>(null);
const renderEngineRef = useRef<RenderEngine | null>(null);
const interactionRef = useRef<InteractionEngine | null>(null);
const spatialIndexRef = useRef<SpatialIndex | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const zoomPan = new ZoomPanController(canvas);
const spatialIndex = new SpatialIndex();
const layerManager = new LayerManager();
const renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager);
const snapEngine = new SnapEngine();
const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager);
const interaction = new InteractionEngine(
canvas, zoomPan, renderEngine, snapEngine, selectionEngine, spatialIndex, layerManager,
);
zoomPanRef.current = zoomPan;
renderEngineRef.current = renderEngine;
interactionRef.current = interaction;
spatialIndexRef.current = spatialIndex;
interaction.setCallbacks({
onElementCreated,
onElementsDeleted,
onElementsModified,
onCursorMoved,
onToolStateChanged,
onTextEdit,
onCommandTrigger,
onSelectionChange,
});
interaction.attach();
return () => {
interaction.detach();
zoomPanRef.current = null;
renderEngineRef.current = null;
interactionRef.current = null;
spatialIndexRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Resize canvas to fill container
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const container = canvas.parentElement;
if (!container) return;
const resize = () => {
const w = container.clientWidth;
const h = container.clientHeight;
if (w > 0 && h > 0) {
canvas.width = w;
canvas.height = h;
renderEngineRef.current?.render();
}
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(container);
window.addEventListener('resize', resize);
return () => {
ro.disconnect();
window.removeEventListener('resize', resize);
};
}, []);
// Sync elements to interaction engine + spatial index + render
useEffect(() => {
const interaction = interactionRef.current;
const spatialIndex = spatialIndexRef.current;
const renderEngine = renderEngineRef.current;
if (!interaction || !spatialIndex || !renderEngine) return;
interaction.setElements(elements);
spatialIndex.clear();
spatialIndex.bulkInsert(elements);
renderEngine.render();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elements]);
// Sync layers to LayerManager + render
useEffect(() => {
const renderEngine = renderEngineRef.current;
if (!renderEngine) return;
renderEngine.setLayers(layers);
renderEngine.render();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layers]);
// Sync blocks to RenderEngine
useEffect(() => {
const renderEngine = renderEngineRef.current;
if (!renderEngine || !blocks) return;
renderEngine.setBlockDefinitions(blocks);
renderEngine.render();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blocks]);
// Sync grid/snap/ortho toggles
useEffect(() => {
const renderEngine = renderEngineRef.current;
const interaction = interactionRef.current;
if (!renderEngine || !interaction) return;
renderEngine.setOptions({ showGrid: gridEnabled });
renderEngine.setOptions({ showSnapPoints: snapEnabled });
renderEngine.setOptions({ showOrtho: orthoEnabled });
interaction.setSnapEnabled(snapEnabled);
interaction.setOrthoEnabled(orthoEnabled);
interaction.setPolarEnabled(polarEnabled);
renderEngine.render();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled]);
// Sync active tool
useEffect(() => {
const interaction = interactionRef.current;
if (!interaction) return;
interaction.setTool(activeTool as ToolType);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTool]);
// Sync selected template to interaction engine
useEffect(() => {
const interaction = interactionRef.current;
if (!interaction) return;
interaction.setSelectedTemplate(selectedTemplate ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTemplate]);
// Compute screen positions for remote cursors
const [cursorScreenPositions, setCursorScreenPositions] = useState<Array<{ cursor: UserCursor; sx: number; sy: number }>>([]);
useEffect(() => {
const zoomPan = zoomPanRef.current;
if (!zoomPan || !remoteCursors || remoteCursors.length === 0) {
setCursorScreenPositions([]);
return;
}
const positions = remoteCursors
.filter((c) => c.visible)
.map((cursor) => {
const screen = zoomPan.worldToScreen(cursor.x, cursor.y);
return { cursor, sx: screen.x, sy: screen.y };
});
setCursorScreenPositions(positions);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remoteCursors]);
// Re-render cursor overlay when canvas renders (zoom/pan changes)
useEffect(() => {
const renderEngine = renderEngineRef.current;
const zoomPan = zoomPanRef.current;
if (!renderEngine || !zoomPan || !remoteCursors || remoteCursors.length === 0) return;
const origRender = renderEngine.render.bind(renderEngine);
renderEngine.render = () => {
origRender();
const positions = remoteCursors
.filter((c) => c.visible)
.map((cursor) => {
const screen = zoomPan.worldToScreen(cursor.x, cursor.y);
return { cursor, sx: screen.x, sy: screen.y };
});
setCursorScreenPositions(positions);
};
return () => { renderEngine.render = origRender; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remoteCursors]);
const handleZoomIn = () => {
const zoomPan = zoomPanRef.current;
const canvas = canvasRef.current;
if (zoomPan && canvas) {
zoomPan.zoomAt(canvas.width / 2, canvas.height / 2, 1.2);
renderEngineRef.current?.render();
}
onZoomIn();
};
const handleZoomOut = () => {
const zoomPan = zoomPanRef.current;
const canvas = canvasRef.current;
if (zoomPan && canvas) {
zoomPan.zoomAt(canvas.width / 2, canvas.height / 2, 0.8);
renderEngineRef.current?.render();
}
onZoomOut();
};
const handleZoomFit = () => {
const interaction = interactionRef.current;
if (interaction) {
interaction.zoomFit();
}
onZoomFit();
};
return (
<section className="canvas-area" id="canvas-area" aria-label="CAD Zeichenfläche">
<div className="canvas-coords" role="status" aria-live="polite">
<span><span className="canvas-coords-label">X</span><span className="canvas-coords-val" id="coord-x">{cursorPos.x.toFixed(3)}</span></span>
<span><span className="canvas-coords-label">Y</span><span className="canvas-coords-val" id="coord-y">{cursorPos.y.toFixed(3)}</span></span>
<span style={{ color: 'var(--color-text-faint)' }}>m</span>
</div>
<div className="canvas-view-tabs" role="tablist" aria-label="Ansicht umschalten">
{viewTabs.map((tab) => (
<button
key={tab.mode}
className={`canvas-view-tab${viewMode === tab.mode ? ' active' : ''}`}
data-view={tab.mode}
onClick={() => onViewChange(tab.mode)}
>
{tab.label}
</button>
))}
</div>
<canvas
ref={canvasRef}
className="canvas-svg"
role="img"
aria-label="CAD Zeichenfläche"
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }}
onDrop={(e) => {
e.preventDefault();
const blockId = e.dataTransfer.getData('text/block-id');
if (!blockId || !onBlockDrop) return;
const rect = e.currentTarget.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const zoomPan = zoomPanRef.current;
if (!zoomPan) return;
const world = zoomPan.screenToWorld(sx, sy);
onBlockDrop(blockId, world.x, world.y);
}}
/>
{cursorScreenPositions.map(({ cursor, sx, sy }) => (
<div
key={cursor.userId}
className="remote-cursor"
style={{
position: 'absolute',
left: `${sx}px`,
top: `${sy}px`,
pointerEvents: 'none',
zIndex: 1000,
}}
>
<div
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: cursor.color,
border: '2px solid white',
boxShadow: '0 0 4px rgba(0,0,0,0.5)',
}}
/>
<div
style={{
position: 'absolute',
left: '16px',
top: '8px',
backgroundColor: cursor.color,
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '11px',
fontFamily: 'sans-serif',
whiteSpace: 'nowrap',
boxShadow: '0 1px 3px rgba(0,0,0,0.4)',
}}
>
{cursor.userName}
</div>
</div>
))}
<div className="canvas-toolbar" role="toolbar" aria-label="Canvas-Werkzeuge">
<button className="canvas-toolbar-btn" title="Herauszoomen (-)" aria-label="Herauszoomen" onClick={handleZoomOut}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<span className="canvas-zoom-display" id="zoom-display">100%</span>
<button className="canvas-toolbar-btn" title="Hineinzoomen (+)" aria-label="Hineinzoomen" onClick={handleZoomIn}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<button className="canvas-toolbar-btn" title="Zoom anpassen (F)" aria-label="Zoom anpassen" onClick={handleZoomFit}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/></svg>
</button>
<div className="canvas-toolbar-divider"></div>
<button className={`canvas-toolbar-btn${gridEnabled ? ' active' : ''}`} title="Grid ein/aus (F7)" aria-label="Grid umschalten" aria-pressed={gridEnabled} onClick={onToggleGrid}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
</button>
<button className={`canvas-toolbar-btn${orthoEnabled ? ' active' : ''}`} title="Ortho-Modus (F8)" aria-label="Ortho-Modus umschalten" aria-pressed={orthoEnabled} onClick={onToggleOrtho}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18M3 21l18-18"/></svg>
</button>
<button className={`canvas-toolbar-btn${snapEnabled ? ' active' : ''}`} title="Snap ein/aus (F3)" aria-label="Snap umschalten" aria-pressed={snapEnabled} onClick={onToggleSnap}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M2 12h4M18 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M5.6 18.4l2.8-2.8M15.6 8.4l2.8-2.8"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<div className="canvas-toolbar-divider"></div>
<button className="canvas-toolbar-btn" title="Vorherige Ansicht" aria-label="Vorherige Ansicht">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
</div>
</section>
);
};
export default CanvasArea;
+253
View File
@@ -0,0 +1,253 @@
import React, { useState, useRef, useMemo, useEffect } from 'react';
import type { CommandLineProps } from '../types/ui.types';
import { getCommandRegistry, type CommandDefinition } from '../services/commandRegistry';
const defaultHistory = [
{ prefix: '·' as const, text: 'Bereit · Werkzeug: Auswahl · 110 Objekte · 5 Ebenen', type: 'info' as const },
{ prefix: '·' as const, text: 'Auto-Save aktiv · letzte Speicherung vor 3 Sekunden', type: 'info' as const },
{ prefix: '' as const, text: 'hallo', type: 'command' as const },
{ prefix: '·' as const, text: 'Hallo! Ich bin der KI Copilot. Tippe KI oder drücke Strg+K für Hilfe.', type: 'info' as const },
{ prefix: '' as const, text: 'BESTUHLUNG 5,22', type: 'command' as const },
{ prefix: '·' as const, text: '110 Stühle angelegt auf Ebene "Bestuhlung" ✓', type: 'info' as const },
];
const categoryColors: Record<string, string> = {
draw: '#22c55e',
modify: '#f97316',
view: '#06b6d4',
meta: '#a855f7',
special: '#ec4899',
};
const CommandLine: React.FC<CommandLineProps> = ({ history, onCommand }) => {
const [input, setInput] = useState('');
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const historyRef = useRef<HTMLDivElement>(null);
const entries = history.length > 0 ? history : defaultHistory;
const suggestions = useMemo(() => {
if (!input.trim()) return [];
const registry = getCommandRegistry();
return registry.autocomplete(input.trim());
}, [input]);
useEffect(() => {
setSelectedSuggestion(0);
}, [suggestions]);
useEffect(() => {
if (historyRef.current) {
historyRef.current.scrollTop = historyRef.current.scrollHeight;
}
}, [entries]);
const executeCommand = (cmd: string) => {
const trimmed = cmd.trim();
if (!trimmed) return;
onCommand(trimmed);
setCommandHistory((prev) => [...prev, trimmed]);
setInput('');
setShowSuggestions(false);
setHistoryIndex(-1);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
const navKeys = ['ArrowUp', 'ArrowDown', 'Tab', 'Enter', 'Escape'];
if (showSuggestions && suggestions.length > 0 && navKeys.includes(e.key)) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedSuggestion((prev) => Math.min(prev + 1, suggestions.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedSuggestion((prev) => Math.max(prev - 1, 0));
return;
}
if (e.key === 'Tab') {
e.preventDefault();
const suggestion = suggestions[selectedSuggestion] || suggestions[0];
if (suggestion) {
setInput(suggestion.name);
setShowSuggestions(false);
}
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const suggestion = suggestions[selectedSuggestion];
if (suggestion) {
executeCommand(suggestion.name);
} else {
executeCommand(input);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setShowSuggestions(false);
return;
}
}
if (!showSuggestions || suggestions.length === 0) {
if (e.key === 'Enter' && input.trim()) {
executeCommand(input);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (commandHistory.length === 0) return;
const newIdx = historyIndex === -1 ? commandHistory.length - 1 : Math.max(historyIndex - 1, 0);
setHistoryIndex(newIdx);
setInput(commandHistory[newIdx]);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex === -1) return;
const newIdx = historyIndex + 1;
if (newIdx >= commandHistory.length) {
setHistoryIndex(-1);
setInput('');
} else {
setHistoryIndex(newIdx);
setInput(commandHistory[newIdx]);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setInput('');
setShowSuggestions(false);
setHistoryIndex(-1);
return;
}
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
setShowSuggestions(true);
setHistoryIndex(-1);
};
const handleBlur = () => {
setTimeout(() => setShowSuggestions(false), 150);
};
const handleFocus = () => {
if (input.trim()) setShowSuggestions(true);
};
const handleSuggestionClick = (cmd: CommandDefinition) => {
executeCommand(cmd.name);
};
return (
<section className="cmdline" aria-label="Befehlszeile">
<div className="cmdline-history" id="cmdline-history" aria-live="polite" ref={historyRef}>
{entries.map((entry, i) => (
<div key={i} className={`cmdline-history-entry${entry.type === 'info' ? ' info' : ''}`}>
<span className="prefix">{entry.prefix}</span>
<span className="text">{entry.text}</span>
</div>
))}
</div>
<div className="cmdline-input-wrap" style={{ position: 'relative' }}>
{showSuggestions && suggestions.length > 0 && (
<div
className="cmdline-suggestions"
style={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
background: '#1e293b',
border: '1px solid #334155',
borderRadius: '6px 6px 0 0',
maxHeight: '240px',
overflowY: 'auto',
zIndex: 1000,
boxShadow: '0 -4px 12px rgba(0,0,0,0.3)',
}}
>
{suggestions.map((cmd, i) => (
<div
key={cmd.name}
className={`cmdline-suggestion${i === selectedSuggestion ? ' selected' : ''}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 12px',
cursor: 'pointer',
background: i === selectedSuggestion ? '#334155' : 'transparent',
color: '#e2e8f0',
fontSize: '13px',
borderBottom: i < suggestions.length - 1 ? '1px solid #334155' : 'none',
}}
onMouseDown={(e) => {
e.preventDefault();
handleSuggestionClick(cmd);
}}
onMouseEnter={() => setSelectedSuggestion(i)}
>
<span
className="cmdline-suggestion-badge"
style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: '4px',
fontSize: '10px',
fontWeight: 600,
textTransform: 'uppercase',
background: categoryColors[cmd.category] || '#64748b',
color: '#fff',
minWidth: '44px',
textAlign: 'center',
}}
>
{cmd.category}
</span>
<span className="cmdline-suggestion-name" style={{ fontWeight: 600 }}>
{cmd.name}
</span>
{cmd.aliases.length > 0 && (
<span className="cmdline-suggestion-aliases" style={{ color: '#64748b', fontSize: '11px' }}>
({cmd.aliases.join(', ')})
</span>
)}
<span className="cmdline-suggestion-desc" style={{ color: '#94a3b8', fontSize: '12px', marginLeft: 'auto' }}>
{cmd.description}
</span>
</div>
))}
</div>
)}
<span className="cmdline-prompt"></span>
<input
className="cmdline-input"
type="text"
id="cmdline-input"
placeholder='Befehl eingeben (z. B. LINIE, KREIS, BESTUHLUNG)...'
aria-label="CAD Befehlseingabe"
autoComplete="off"
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={handleFocus}
ref={inputRef}
/>
</div>
</section>
);
};
export default CommandLine;
+77
View File
@@ -0,0 +1,77 @@
/**
* HistoryPanel Zeigt die Undo/Redo-Historie an.
* F-CAD-09: Historie einsehbar
*/
import React from 'react';
import type { HistoryEntry } from '../history';
interface HistoryPanelProps {
entries: HistoryEntry[];
onJumpTo: (entryId: string) => void;
onClose: () => void;
}
const formatTime = (ts: number): string => {
const d = new Date(ts);
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
};
const HistoryPanel: React.FC<HistoryPanelProps> = ({ entries, onJumpTo, onClose }) => {
if (entries.length === 0) {
return (
<div style={{
position: 'fixed', top: '50%', right: '320px', transform: 'translateY(-50%)',
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
padding: '16px', width: '280px', maxHeight: '60vh', overflow: 'auto',
border: '1px solid var(--color-border, #333)', zIndex: 900,
color: 'var(--color-text, #e0e0e0)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '15px' }}>Historie</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: '18px' }}>×</button>
</div>
<p style={{ fontSize: '13px', color: '#888' }}>Keine Historie vorhanden.</p>
</div>
);
}
return (
<div style={{
position: 'fixed', top: '50%', right: '320px', transform: 'translateY(-50%)',
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
padding: '16px', width: '280px', maxHeight: '60vh', overflow: 'auto',
border: '1px solid var(--color-border, #333)', zIndex: 900,
color: 'var(--color-text, #e0e0e0)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '15px' }}>Historie ({entries.length})</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: '18px' }}>×</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{entries.map((entry, idx) => (
<button
key={entry.id}
onClick={() => onJumpTo(entry.id)}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '6px 8px', borderRadius: '4px', cursor: 'pointer',
border: 'none', textAlign: 'left', width: '100%',
background: entry.isCurrent ? 'rgba(80, 250, 123, 0.15)' : 'transparent',
color: entry.isCurrent ? '#50fa7b' : 'var(--color-text, #e0e0e0)',
fontWeight: entry.isCurrent ? 600 : 400,
fontSize: '13px',
opacity: entry.id.startsWith('redo-') ? 0.5 : 1,
}}
>
<span style={{ fontSize: '11px', color: '#666', minWidth: '28px' }}>{formatTime(entry.timestamp)}</span>
<span style={{ flex: 1 }}>{entry.label}</span>
{entry.isCurrent && <span style={{ fontSize: '10px' }}></span>}
</button>
))}
</div>
</div>
);
};
export default HistoryPanel;
+88
View File
@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import type { KICopilotProps } from '../types/ui.types';
const defaultSuggestions = [
{ id: 's1', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>, label: '5 Reihen à 22 Stühle anlegen' },
{ id: 's2', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>, label: 'Optimale Bestuhlung vorschlagen' },
{ id: 's3', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>, label: 'Überlappende Stühle finden' },
];
const defaultMessages = [
{ id: 'm1', role: 'user' as const, content: 'Lege 5 Reihen mit je 22 Stühlen parallel zur Bühne an, Abstand 1m.' },
{ id: 'm2', role: 'assistant' as const, content: <span>Ich erstelle <strong>110 Stühle</strong> in 5 Reihen (Y=2,5m/3,5m/4,5m/5,5m/6,5m; X-Start=2,5m; 22 Stühle × 0,5m + 0,1m Abstand). Los geht's! <em style={{ color: 'var(--color-ki)' }}>● function_call: placeSeating(rows=5, cols=22, gap=1.0)</em></span> },
];
const KICopilot: React.FC<KICopilotProps> = ({ messages, suggestions, onSend, onSuggestionClick, loading }) => {
const [input, setInput] = useState('');
const allMessages = messages.length > 0 ? messages : defaultMessages;
const allSuggestions = suggestions.length > 0 ? suggestions : defaultSuggestions;
const handleSend = () => {
if (input.trim()) {
onSend(input.trim());
setInput('');
}
};
return (
<>
<div className="ki-header">
<div className="ki-avatar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
</div>
<div>
<div className="ki-title">KI Copilot</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>Powered by Claude</div>
</div>
<span className="ki-status">Online</span>
</div>
<div className="ki-suggestions">
<div className="ki-suggestion-title">Schnell-Aktionen</div>
{allSuggestions.map((s) => (
<button key={s.id} className="ki-chip" onClick={() => onSuggestionClick(s)}>
{s.icon}
{s.label}
</button>
))}
</div>
<div className="ki-chat">
{allMessages.map((msg) => (
<div key={msg.id} className={`ki-msg ${msg.role}`}>
<div className="ki-msg-bubble">{msg.content}</div>
</div>
))}
{loading && (
<div className="ki-msg assistant">
<div className="ki-msg-bubble" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className="ki-typing-dot" />
<span className="ki-typing-dot" />
<span className="ki-typing-dot" />
</div>
</div>
)}
</div>
<div className="ki-input-wrap">
<input
className="ki-input"
type="text"
placeholder="Frage den KI Copilot..."
aria-label="KI Eingabe"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSend(); }}
/>
<button className="ki-voice-btn" title="Spracheingabe" aria-label="Spracheingabe starten">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</button>
<button className="ki-send-btn" title="Senden" aria-label="Senden" onClick={handleSend} disabled={loading}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</>
);
};
export default KICopilot;
+260
View File
@@ -0,0 +1,260 @@
import React, { useState } from 'react';
import type { LayerPanelProps } from '../types/ui.types';
import type { CADLayer } from '../types/cad.types';
import type { TreeNode } from '../types/ui.types';
import TreeView from './TreeView';
const lineTypeOptions: Array<{ value: 'solid' | 'dashed' | 'dotted'; label: string }> = [
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
];
const LayerPanel: React.FC<LayerPanelProps> = ({
layers,
activeLayerId,
onSelectLayer,
onAddLayer,
onToggleLayer,
onDeleteLayer,
onRenameLayer,
onDuplicateLayer,
onToggleLock,
onUpdateLayerColor,
onUpdateLayerLineType,
onUpdateLayerTransparency,
}) => {
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
// Build tree nodes from layers (parentId hierarchy)
const buildTree = (parentId: string | null): TreeNode[] => {
return layers
.filter((l) => l.parentId === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((l) => ({
id: l.id,
name: l.name,
expanded: true,
active: l.id === activeLayerId,
children: buildTree(l.id),
}));
};
const nodes = buildTree(null);
const handleStartRename = (layer: CADLayer) => {
setEditingId(layer.id);
setEditName(layer.name);
};
const handleConfirmRename = () => {
if (editingId && onRenameLayer) {
onRenameLayer(editingId, editName);
}
setEditingId(null);
setEditName('');
};
const renderLayerActions = (node: TreeNode) => {
const layer = layers.find((l) => l.id === node.id);
if (!layer) return null;
return (
<React.Fragment>
{/* Visibility toggle */}
<button
className="layer-action-btn"
title={layer.visible ? 'Verstecken' : 'Anzeigen'}
aria-label={layer.visible ? 'Verstecken' : 'Anzeigen'}
onClick={(e) => {
e.stopPropagation();
onToggleLayer(layer.id);
}}
>
{layer.visible ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
)}
</button>
{/* Lock toggle */}
{onToggleLock && (
<button
className="layer-action-btn"
title={layer.locked ? 'Entsperren' : 'Sperren'}
aria-label={layer.locked ? 'Entsperren' : 'Sperren'}
onClick={(e) => {
e.stopPropagation();
onToggleLock(layer.id);
}}
>
{layer.locked ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>
)}
</button>
)}
{/* Color swatch */}
{onUpdateLayerColor && (
<label
className="layer-color-swatch"
title="Ebenenfarbe"
style={{ backgroundColor: layer.color, display: 'inline-block', width: '14px', height: '14px', borderRadius: '3px', cursor: 'pointer', border: '1px solid var(--color-border)' }}
>
<input
type="color"
value={layer.color}
onChange={(e) => onUpdateLayerColor(layer.id, e.target.value)}
style={{ opacity: 0, width: 0, height: 0 }}
/>
</label>
)}
</React.Fragment>
);
};
const renderLayerDetail = (layer: CADLayer) => {
if (editingId === layer.id) {
return (
<div className="layer-edit-row" style={{ display: 'flex', gap: '4px', padding: '4px 8px' }}>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmRename();
if (e.key === 'Escape') setEditingId(null);
}}
autoFocus
style={{ flex: 1, fontSize: '12px' }}
/>
<button onClick={handleConfirmRename} title="Bestätigen"></button>
<button onClick={() => setEditingId(null)} title="Abbrechen"></button>
</div>
);
}
return (
<div className="layer-detail-row" style={{ display: 'flex', gap: '6px', padding: '2px 8px', alignItems: 'center', fontSize: '11px', color: 'var(--color-text-muted)' }}>
{/* Line type selector */}
{onUpdateLayerLineType && (
<select
value={layer.lineType}
onChange={(e) => onUpdateLayerLineType(layer.id, e.target.value as 'solid' | 'dashed' | 'dotted')}
style={{ fontSize: '10px', padding: '1px 4px' }}
title="Linientyp"
>
{lineTypeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
)}
{/* Transparency slider */}
{onUpdateLayerTransparency && (
<React.Fragment>
<input
type="range"
min="0"
max="100"
value={layer.transparency}
onChange={(e) => onUpdateLayerTransparency(layer.id, parseInt(e.target.value, 10))}
style={{ width: '60px' }}
title={`Transparenz: ${layer.transparency}%`}
/>
<span style={{ minWidth: '28px' }}>{layer.transparency}%</span>
</React.Fragment>
)}
{/* Rename button */}
{onRenameLayer && (
<button
className="layer-action-btn"
title="Umbenennen"
onClick={(e) => {
e.stopPropagation();
handleStartRename(layer);
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
)}
{/* Duplicate button */}
{onDuplicateLayer && (
<button
className="layer-action-btn"
title="Duplizieren"
onClick={(e) => {
e.stopPropagation();
onDuplicateLayer(layer.id);
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
)}
{/* Delete button */}
{onDeleteLayer && (
<button
className="layer-action-btn"
title="Löschen"
onClick={(e) => {
e.stopPropagation();
onDeleteLayer(layer.id);
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
)}
</div>
);
};
// Render expanded layer list with details
const renderLayerList = () => {
return layers
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((layer) => (
<div key={layer.id} className={`layer-item${layer.id === activeLayerId ? ' active' : ''}`}>
<div
className="layer-item-header"
style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 8px', cursor: 'pointer' }}
onClick={() => onSelectLayer(layer.id)}
>
<span
className="layer-color-dot"
style={{ width: '10px', height: '10px', borderRadius: '2px', backgroundColor: layer.color, flexShrink: 0 }}
/>
<span className="layer-name" style={{ flex: 1, fontSize: '12px' }}>{layer.name}</span>
{renderLayerActions({ id: layer.id, name: layer.name })}
</div>
{renderLayerDetail(layer)}
</div>
));
};
return (
<div className="panel-section">
<div className="panel-section-title">
<span>Ebenen-Struktur</span>
<button style={{ color: 'var(--color-text-muted)' }} title="Optionen" aria-label="Layer-Optionen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
<div className="tree tree-layer" role="tree" aria-label="Layer-Struktur">
<TreeView nodes={nodes} selectedId={activeLayerId} onSelect={onSelectLayer} onToggle={onToggleLayer} renderActions={renderLayerActions} renderDetail={(node) => { const layer = layers.find(l => l.id === node.id); return layer ? renderLayerDetail(layer) : null; }} />
</div>
<button className="add-layer-btn" onClick={onAddLayer}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Ebene hinzufügen
</button>
</div>
);
};
export default LayerPanel;
+138
View File
@@ -0,0 +1,138 @@
import React from 'react';
import type { LeftSidebarProps } from '../types/ui.types';
import { SEATING_TEMPLATES } from '../services/seatingService';
interface ToolDef {
tool: string;
title: string;
label: string;
kbd: string;
svg: React.ReactNode;
}
const sectionAuswahlen: ToolDef[] = [
{ tool: 'select', title: 'Auswählen (V)', label: 'Auswahl', kbd: 'V', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="m13 13 6 6"/></svg> },
{ tool: 'pan', title: 'Pan (P / Leertaste)', label: 'Pan', kbd: 'P', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2 2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg> },
{ tool: 'zoom-win', title: 'Zoom-Fenster (Z)', label: 'Zoom', kbd: 'Z', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/></svg> },
{ tool: 'measure', title: 'Messen', label: 'Messen', kbd: 'M', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0L2.7 16.7a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4z"/><path d="m7.5 10.5 2 2"/><path d="m10.5 7.5 2 2"/></svg> },
];
const sectionZeichnen: ToolDef[] = [
{ tool: 'line', title: 'Linie (L)', label: 'Linie', kbd: 'L', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="19" x2="19" y2="5"/></svg> },
{ tool: 'polyline', title: 'Polylinie (PL)', label: 'Polylinie', kbd: 'PL', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 14 15 20 7"/></svg> },
{ tool: 'rect', title: 'Rechteck (REC)', label: 'Rechteck', kbd: 'REC', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="1"/></svg> },
{ tool: 'circle', title: 'Kreis (C)', label: 'Kreis', kbd: 'C', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg> },
{ tool: 'arc', title: 'Bogen (A)', label: 'Bogen', kbd: 'A', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/><path d="M3 12a9 9 0 0 1 9-9"/></svg> },
{ tool: 'text', title: 'Text (T)', label: 'Text', kbd: 'T', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg> },
{ tool: 'dimension', title: 'Bemaßung (DIM)', label: 'Bemaßung', kbd: 'DIM', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 3H3l3 18 3-9 6 9 3-9 3 9 3-18z"/><line x1="3" y1="3" x2="21" y2="21"/></svg> },
{ tool: 'hatch', title: 'Schraffur (H)', label: 'Schraffur', kbd: 'H', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="0"/><line x1="3" y1="9" x2="9" y2="3"/><line x1="9" y1="21" x2="21" y2="9"/><line x1="15" y1="21" x2="21" y2="15"/></svg> },
{ tool: 'leader', title: 'Hinweislinie (LD)', label: 'Hinweis', kbd: 'LD', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21 12 12"/><path d="M12 12 18 6"/><polyline points="18 3 18 6 21 6"/><line x1="18" y1="6" x2="21" y2="3"/></svg> },
{ tool: 'revcloud', title: 'Revisionswolke (REV)', label: 'RevCloud', kbd: 'REV', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 18c-1-3 1-5 3-4 0-3 3-4 5-2 1-3 5-3 6 0 2-1 5 1 4 4 1 2-2 4-4 3-1 2-4 2-5 0-2 1-5-1-4-3-2-1-3-4-1-5z"/></svg> },
];
const sectionBearbeiten: ToolDef[] = [
{ tool: 'move', title: 'Verschieben (M)', label: 'Move', kbd: 'M', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg> },
{ tool: 'copy', title: 'Kopieren (CO)', label: 'Copy', kbd: 'CO', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> },
{ tool: 'rotate', title: 'Rotieren (RO)', label: 'Rotate', kbd: 'RO', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> },
{ tool: 'scale', title: 'Skalieren (SC)', label: 'Scale', kbd: 'SC', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg> },
{ tool: 'mirror', title: 'Spiegeln (MI)', label: 'Mirror', kbd: 'MI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 8l5-5 5 5"/><path d="M5 16l5 5 5-5"/></svg> },
{ tool: 'trim', title: 'Trimmen (TR)', label: 'Trim', kbd: 'TR', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> },
{ tool: 'offset', title: 'Versatz (O)', label: 'Offset', kbd: 'O', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h12"/><path d="M3 12h18"/><path d="M3 18h12"/><path d="M21 6v12"/></svg> },
{ tool: 'delete', title: 'Löschen (E)', label: 'Löschen', kbd: 'E', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> },
];
const sectionBestuhlung: ToolDef[] = [
{ tool: 'seating-row', title: 'Reihen-Bestuhlung', label: 'Reihe', kbd: 'R', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="4" height="3" rx="0.5"/><rect x="10" y="5" width="4" height="3" rx="0.5"/><rect x="17" y="5" width="4" height="3" rx="0.5"/><rect x="3" y="11" width="4" height="3" rx="0.5"/><rect x="10" y="11" width="4" height="3" rx="0.5"/><rect x="17" y="11" width="4" height="3" rx="0.5"/><rect x="3" y="17" width="4" height="3" rx="0.5"/><rect x="10" y="17" width="4" height="3" rx="0.5"/><rect x="17" y="17" width="4" height="3" rx="0.5"/></svg> },
{ tool: 'seating-block', title: 'Block-Bestuhlung', label: 'Block', kbd: 'B', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><rect x="6" y="6" width="3" height="3"/><rect x="10.5" y="6" width="3" height="3"/><rect x="15" y="6" width="3" height="3"/><rect x="6" y="10.5" width="3" height="3"/><rect x="10.5" y="10.5" width="3" height="3"/><rect x="15" y="10.5" width="3" height="3"/><rect x="6" y="15" width="3" height="3"/><rect x="10.5" y="15" width="3" height="3"/><rect x="15" y="15" width="3" height="3"/></svg> },
{ tool: 'table', title: 'Tisch (TAB)', label: 'Tisch', kbd: 'TAB', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="9" width="16" height="6" rx="1"/><line x1="6" y1="15" x2="6" y2="19"/><line x1="18" y1="15" x2="18" y2="19"/></svg> },
{ tool: 'stage', title: 'Bühne', label: 'Bühne', kbd: 'S', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 17h20v3H2z"/><path d="M5 14V8h14v6"/><path d="M9 14v-3"/><path d="M15 14v-3"/></svg> },
{ tool: 'seating-template', title: 'Vorlagen', label: 'Vorlagen', kbd: 'TPL', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg> },
];
const sections: Array<{ label: string; tools: ToolDef[] }> = [
{ label: 'Auswählen / Anzeigen', tools: sectionAuswahlen },
{ label: 'Zeichnen', tools: sectionZeichnen },
{ label: 'Bearbeiten', tools: sectionBearbeiten },
{ label: 'Bestuhlung', tools: sectionBestuhlung },
];
const LeftSidebar: React.FC<LeftSidebarProps> = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect }) => {
return (
<aside className="leftbar" aria-label="Werkzeugpalette">
<div className="leftbar-header">
<span className="leftbar-title">Werkzeuge</span>
<button className="leftbar-toggle" aria-label="Linke Leiste ausblenden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
</div>
{sections.map((section) => (
<div className="tool-section" key={section.label}>
<div className="tool-section-label">{section.label}</div>
<div className="tool-grid">
{section.tools.map((t) => (
<button
key={t.tool}
className={`tool-btn${activeTool === t.tool ? ' active' : ''}`}
data-tool={t.tool}
title={t.title}
onClick={() => onToolChange(t.tool)}
>
{t.svg}
<span className="tool-btn-label">{t.label}</span>
<span className="tool-btn-kbd">{t.kbd}</span>
</button>
))}
</div>
</div>
))}
{activeTool === 'seating-template' && onTemplateSelect && (
<div className="tool-section" key="templates">
<div className="tool-section-label">Vorlagen</div>
<select
className="template-dropdown"
value={selectedTemplate ?? ''}
onChange={(e) => onTemplateSelect(e.target.value || null)}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid var(--color-border)',
background: 'var(--color-bg-secondary)',
color: 'var(--color-text)',
fontSize: '13px',
cursor: 'pointer',
}}
>
<option value=""> Vorlage wählen </option>
{SEATING_TEMPLATES.map((t) => (
<option key={t.name} value={t.name}>
{t.name} ({t.description})
</option>
))}
</select>
{selectedTemplate && (
<div style={{ marginTop: '6px', fontSize: '12px', color: 'var(--color-text-faint)' }}>
Klicken Sie auf die Zeichenfläche, um die Vorlage zu platzieren.
</div>
)}
</div>
)}
<div className="leftbar-footer">
<button className="leftbar-footer-btn" title="Plugin hinzufügen" aria-label="Plugin hinzufügen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
</button>
<button className="leftbar-footer-btn" title="Werkzeugkasten anpassen" aria-label="Werkzeugkasten anpassen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button className="leftbar-footer-btn" title="Hilfe (F1)" aria-label="Hilfe">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</button>
</div>
</aside>
);
};
export default LeftSidebar;
+59
View File
@@ -0,0 +1,59 @@
import React from 'react';
import type { MobileDrawersProps, DrawerTab } from '../types/ui.types';
const drawerTabs: Array<{ id: DrawerTab; label: string; svg: React.ReactNode }> = [
{ id: 'tool', label: 'Wz', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> },
{ id: 'layer', label: 'Layer', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg> },
{ id: 'library', label: 'Lib', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg> },
{ id: 'ki', label: 'KI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/></svg> },
];
const MobileDrawers: React.FC<MobileDrawersProps> = ({
leftOpen, rightOpen, activeRightTab, onCloseLeft, onCloseRight, onRightTabChange,
}) => {
return (
<>
<div className={`scrim${(leftOpen || rightOpen) ? ' show' : ''}`} aria-hidden="true" onClick={() => { onCloseLeft(); onCloseRight(); }}></div>
<aside className={`drawer drawer-left${leftOpen ? ' open' : ''}`} aria-label="Werkzeugpalette" aria-hidden={!leftOpen}>
<div className="drawer-header">
<span className="drawer-title">Werkzeuge</span>
<button className="drawer-close" aria-label="Schließen" onClick={onCloseLeft}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div className="drawer-body" id="drawer-left-body">
{/* Filled by LeftSidebar content on mobile */}
</div>
</aside>
<aside className={`drawer drawer-right${rightOpen ? ' open' : ''}`} aria-label="Panel" aria-hidden={!rightOpen}>
<div className="drawer-header">
<span className="drawer-title" id="drawer-right-title">Werkzeug</span>
<button className="drawer-close" aria-label="Schließen" onClick={onCloseRight}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div className="drawer-tabs" id="drawer-right-tabs" role="tablist">
{drawerTabs.map((tab) => (
<button
key={tab.id}
className={`drawer-tab${activeRightTab === tab.id ? ' active' : ''}`}
data-drawer-tab={tab.id}
role="tab"
onClick={() => onRightTabChange(tab.id)}
>
{tab.svg}
<span>{tab.label}</span>
</button>
))}
</div>
<div className="drawer-body" id="drawer-right-body">
{/* Filled by panel content on mobile */}
</div>
</aside>
</>
);
};
export default MobileDrawers;
+84
View File
@@ -0,0 +1,84 @@
/**
* PluginManager UI component for managing plugins
*/
import React, { useState, useEffect } from 'react';
import { pluginRegistry } from '../plugins';
import type { PluginState } from '../plugins';
const categoryLabels: Record<string, string> = {
tools: 'Werkzeuge',
elements: 'Elemente',
'import-export': 'Import/Export',
theme: 'Theme',
other: 'Sonstige',
};
const categoryIcons: Record<string, React.ReactNode> = {
tools: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
elements: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>,
'import-export': <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>,
theme: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>,
other: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>,
};
const PluginManager: React.FC = () => {
const [states, setStates] = useState<PluginState[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
useEffect(() => {
const update = () => setStates(pluginRegistry.getStates());
update();
return pluginRegistry.subscribe(update);
}, []);
const handleToggle = (id: string) => {
pluginRegistry.toggle(id);
};
if (states.length === 0) {
return (
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--color-text-muted)' }}>
Keine Plugins installiert.
</div>
);
}
return (
<div className="plugin-manager">
<div className="plugin-manager-header">
<strong>Plugins</strong>
<span style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>{states.filter(s => s.enabled).length} aktiv · {states.length} gesamt</span>
</div>
{states.map((state) => (
<div key={state.manifest.id} className={`plugin-card ${state.enabled ? 'enabled' : ''} ${expandedId === state.manifest.id ? 'expanded' : ''}`}>
<div className="plugin-card-header" onClick={() => setExpandedId(expandedId === state.manifest.id ? null : state.manifest.id)}>
<div className="plugin-icon" style={{ color: state.enabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>
{categoryIcons[state.manifest.category] || categoryIcons.other}
</div>
<div className="plugin-info">
<div className="plugin-name">{state.manifest.name}</div>
<div className="plugin-meta">v{state.manifest.version} · {categoryLabels[state.manifest.category] || state.manifest.category}</div>
</div>
<button
className={`plugin-toggle ${state.enabled ? 'on' : 'off'}`}
onClick={(e) => { e.stopPropagation(); handleToggle(state.manifest.id); }}
aria-label={state.enabled ? 'Deaktivieren' : 'Aktivieren'}
role="switch"
aria-checked={state.enabled}
>
<span className="plugin-toggle-knob" />
</button>
</div>
{expandedId === state.manifest.id && (
<div className="plugin-card-body">
<p className="plugin-description">{state.manifest.description}</p>
<div className="plugin-author">Autor: {state.manifest.author}</div>
</div>
)}
</div>
))}
</div>
);
};
export default PluginManager;
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
import type { PropertiesPanelProps } from '../types/ui.types';
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ selectedElement, layers, onUpdateProperty }) => {
const el = selectedElement;
const x = el ? el.x.toFixed(3) + ' m' : '0.000 m';
const y = el ? el.y.toFixed(3) + ' m' : '0.000 m';
const w = el ? el.width.toFixed(2) + ' m' : '0.50 m';
const h = el ? el.height.toFixed(2) + ' m' : '0.50 m';
const rot = el ? (el.properties.rotation ?? 0).toFixed(2) + '°' : '0.00°';
const color = (el?.properties.stroke as string) || '#3b82f6';
const blockName = el?.properties.blockId || 'Stuhl-Standard';
const desc = el?.properties.text as string || 'Standard-Konferenzstuhl 0.5×0.5m';
return (
<>
<div className="panel-section">
<div className="panel-section-title">
<span>Auswahl · 1 Stuhl</span>
<button style={{ color: 'var(--color-text-muted)', fontSize: '11px' }} title="Mehr Optionen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
</div>
</div>
<div className="panel-section">
<div className="panel-section-title"><span>Geometrie</span></div>
<div className="prop-row">
<span className="prop-label">Position X</span>
<input className="prop-input" type="text" value={x} aria-label="Position X" onChange={(e) => onUpdateProperty('x', e.target.value)} />
</div>
<div className="prop-row">
<span className="prop-label">Position Y</span>
<input className="prop-input" type="text" value={y} aria-label="Position Y" onChange={(e) => onUpdateProperty('y', e.target.value)} />
</div>
<div className="prop-row">
<span className="prop-label">Breite</span>
<input className="prop-input" type="text" value={w} aria-label="Breite" onChange={(e) => onUpdateProperty('width', e.target.value)} />
</div>
<div className="prop-row">
<span className="prop-label">Tiefe</span>
<input className="prop-input" type="text" value={h} aria-label="Tiefe" onChange={(e) => onUpdateProperty('height', e.target.value)} />
</div>
<div className="prop-row">
<span className="prop-label">Drehung</span>
<input className="prop-input" type="text" value={rot} aria-label="Drehung" onChange={(e) => onUpdateProperty('rotation', e.target.value)} />
</div>
</div>
<div className="panel-section">
<div className="panel-section-title"><span>Darstellung</span></div>
<div className="prop-row">
<span className="prop-label">Farbe</span>
<div className="prop-color-row">
<div className="prop-color-swatch" style={{ background: color }} aria-hidden="true"></div>
<input className="prop-swatch-input" type="color" value={color} aria-label="Stuhlfarbe" onChange={(e) => onUpdateProperty('stroke', e.target.value)} />
<input className="prop-input" type="text" value={color.toUpperCase()} aria-label="Farb-Hex" onChange={(e) => onUpdateProperty('stroke', e.target.value)} />
</div>
</div>
<div className="prop-row">
<span className="prop-label">Linientyp</span>
<select className="prop-select" aria-label="Linientyp" onChange={(e) => onUpdateProperty('lineType', e.target.value)}>
<option>Solid (durchgehend)</option>
<option>Dashed (gestrichelt)</option>
<option>Dotted (gepunktet)</option>
<option>Dash-Dot</option>
</select>
</div>
<div className="prop-row">
<span className="prop-label">Stärke</span>
<select className="prop-select" aria-label="Linienstärke" onChange={(e) => onUpdateProperty('strokeWidth', e.target.value)}>
<option>0.18 mm · Normal</option>
<option>0.25 mm · Dick</option>
<option>0.35 mm · Extra</option>
<option>0.50 mm · Max</option>
</select>
</div>
<div className="prop-row">
<span className="prop-label">Layer</span>
<select className="prop-select" aria-label="Layer zuweisen" onChange={(e) => onUpdateProperty('layerId', e.target.value)}>
{layers.length > 0 ? layers.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
)) : (
<>
<option>Bestuhlung · Standard</option>
<option>Bestuhlung · VIP</option>
<option>Möbel · Tische</option>
<option>Bühne</option>
</>
)}
</select>
</div>
</div>
<div className="panel-section">
<div className="panel-section-title"><span>Block</span></div>
<div className="prop-row">
<span className="prop-label">Block-Name</span>
<input className="prop-input" type="text" value={blockName} aria-label="Block-Name" onChange={(e) => onUpdateProperty('blockId', e.target.value)} />
</div>
<div className="prop-row">
<span className="prop-label">Beschreibung</span>
<input className="prop-input" type="text" value={desc} aria-label="Beschreibung" onChange={(e) => onUpdateProperty('text', e.target.value)} />
</div>
</div>
</>
);
};
export default PropertiesPanel;
+144
View File
@@ -0,0 +1,144 @@
import React from 'react';
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
const tabs: Array<{ id: RibbonTab; label: string; svg: React.ReactNode }> = [
{ id: 'start', label: 'Start', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> },
{ id: 'insert', label: 'Einfügen', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> },
{ id: 'format', label: 'Format', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg> },
{ id: 'view', label: 'Ansicht', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg> },
{ id: 'tools', label: 'Extras', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> },
{ id: 'ki', label: 'KI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg> },
];
const RibbonBar: React.FC<RibbonBarProps> = ({ activeTab, onTabChange, onAction }) => {
return (
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
<div className="ribbon-tabs" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
className={`ribbon-tab${activeTab === tab.id ? ' active' : ''}`}
role="tab"
aria-selected={activeTab === tab.id}
data-tab={tab.id}
style={tab.id === 'ki' ? { color: 'var(--color-ki)' } : undefined}
onClick={() => onTabChange(tab.id)}
>
{tab.svg}
<span>{tab.label}</span>
</button>
))}
</div>
<div className="ribbon-content" id="ribbon-content">
<div className="ribbon-group compact-mobile" data-ribbon-group="start">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Neues Projekt (Strg+N)" onClick={() => onAction('new')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
<span>Neu</span>
</button>
<button className="ribbon-btn" title="Öffnen (Strg+O)" onClick={() => onAction('open')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<span>Öffnen</span>
</button>
<button className="ribbon-btn" title="Speichern (Strg+S)" onClick={() => onAction('save')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
<span>Speichern</span>
</button>
<button className="ribbon-btn" title="Datei importieren (DXF, SVG, JSON)" onClick={() => onAction('import')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span>Import</span>
</button>
<button className="ribbon-btn" title="Export als (DXF, SVG, PDF, PNG, JSON)" onClick={() => onAction('export')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>Export</span>
</button>
</div>
<div className="ribbon-group-label">Datei</div>
</div>
<div className="ribbon-divider"></div>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Rückgängig (Strg+Z)" onClick={() => onAction('undo')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
<span>Undo</span>
</button>
<button className="ribbon-btn" title="Wiederherstellen (Strg+Y)" onClick={() => onAction('redo')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>
<span>Redo</span>
</button>
<button className="ribbon-btn" title="Kopieren (Strg+C)" onClick={() => onAction('copy')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span>Kopieren</span>
</button>
<button className="ribbon-btn" title="Einfügen (Strg+V)" onClick={() => onAction('paste')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>
<span>Einfügen</span>
</button>
</div>
<div className="ribbon-group-label">Bearbeiten</div>
</div>
<div className="ribbon-divider"></div>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Zoom anpassen (F)" onClick={() => onAction('zoom-fit')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/></svg>
<span>Zoom Fit</span>
</button>
<button className="ribbon-btn" title="100% (1:1)" onClick={() => onAction('zoom-100')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
<span>100%</span>
</button>
<button className="ribbon-btn" title="Grid ein/aus (F7)" onClick={() => onAction('grid')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="0"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
<span>Grid</span>
</button>
<button className="ribbon-btn" title="Layer-Manager" onClick={() => onAction('layer')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
<span>Layer</span>
</button>
<button className="ribbon-btn" title="Hintergrund importieren" onClick={() => onAction('background-import')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
<span>Hintergrund</span>
</button>
</div>
<div className="ribbon-group-label">Ansicht</div>
</div>
<div className="ribbon-divider"></div>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Messwerkzeug" onClick={() => onAction('measure')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0L2.7 16.7a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4z"/><path d="m7.5 10.5 2 2"/><path d="m10.5 7.5 2 2"/><path d="m13.5 4.5 2 2"/><path d="m4.5 13.5 2 2"/></svg>
<span>Messen</span>
</button>
<button className="ribbon-btn" title="Suchen (Strg+F)" onClick={() => onAction('search')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Suchen</span>
</button>
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Copilot (Ctrl+K)" onClick={() => onAction('ki')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
<span>KI</span>
</button>
<button className="ribbon-btn" title="Plugins" onClick={() => onAction('plugins')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6m0 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/><path d="M12 8a6 6 0 0 0-6 6c0 3 2 4 2 6h8c0-2 2-3 2-6a6 6 0 0 0-6-6z"/></svg>
<span>Plugins</span>
</button>
<button className="ribbon-btn" title="Historie (Strg+H)" onClick={() => onAction('history')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>
<span>Historie</span>
</button>
</div>
<div className="ribbon-group-label">Extras</div>
</div>
</div>
</nav>
);
};
export default RibbonBar;
+66
View File
@@ -0,0 +1,66 @@
import React from 'react';
import type { RightSidebarProps, RightPanel } from '../types/ui.types';
import PropertiesPanel from './PropertiesPanel';
import LayerPanel from './LayerPanel';
import BlockLibrary from './BlockLibrary';
import KICopilot from './KICopilot';
import PluginManager from './PluginManager';
const tabs: Array<{ id: RightPanel; label: string; svg: React.ReactNode; badge?: number }> = [
{ id: 'tool', label: 'Werkzeug', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> },
{ id: 'layer', label: 'Layer', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg> },
{ id: 'library', label: 'Bibliothek', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><line x1="3" y1="12" x2="21" y2="12"/></svg> },
{ id: 'ki', label: 'KI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>, badge: 2 },
{ id: 'plugins', label: 'Plugins', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> },
];
const RightSidebar: React.FC<RightSidebarProps> = ({
activePanel, onPanelChange, selectedElement, layers, blocks,
activeLayerId, onSelectLayer, onAddLayer, onToggleLayer,
onDeleteLayer, onRenameLayer, onDuplicateLayer, onToggleLock,
onUpdateLayerColor, onUpdateLayerLineType, onUpdateLayerTransparency,
onRenameBlock, onDuplicateBlock, onDeleteBlock, onSvgImport, onSaveGroupAsBlock,
onBlockCategoryChange, onBlockSearch, onDragBlock,
kiMessages, kiSuggestions, onKISend, onKISuggestionClick, kiLoading,
}) => {
return (
<aside className="rightbar" aria-label="Eigenschaften-Panels">
<div className="rightbar-tabs" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
className={`rightbar-tab${activePanel === tab.id ? ' active' : ''}`}
data-panel={tab.id}
role="tab"
aria-selected={activePanel === tab.id}
onClick={() => onPanelChange(tab.id)}
>
{tab.svg}
<span>{tab.label}</span>
{tab.badge !== undefined && <span className="rightbar-tab-badge">{tab.badge}</span>}
</button>
))}
</div>
<div className="rightbar-content">
<div className={`rightbar-panel${activePanel === 'tool' ? ' active' : ''}`} data-panel-content="tool">
{activePanel === 'tool' && <PropertiesPanel selectedElement={selectedElement} layers={layers} onUpdateProperty={() => {}} />}
</div>
<div className={`rightbar-panel${activePanel === 'layer' ? ' active' : ''}`} data-panel-content="layer">
{activePanel === 'layer' && <LayerPanel layers={layers} activeLayerId={activeLayerId} onSelectLayer={onSelectLayer ?? (() => {})} onAddLayer={onAddLayer ?? (() => {})} onToggleLayer={onToggleLayer ?? (() => {})} onDeleteLayer={onDeleteLayer} onRenameLayer={onRenameLayer} onDuplicateLayer={onDuplicateLayer} onToggleLock={onToggleLock} onUpdateLayerColor={onUpdateLayerColor} onUpdateLayerLineType={onUpdateLayerLineType} onUpdateLayerTransparency={onUpdateLayerTransparency} />}
</div>
<div className={`rightbar-panel${activePanel === 'library' ? ' active' : ''}`} data-panel-content="library">
{activePanel === 'library' && <BlockLibrary blocks={blocks} category="Alle" onCategoryChange={onBlockCategoryChange ?? (() => {})} onSearch={onBlockSearch ?? (() => {})} onDragBlock={onDragBlock ?? (() => {})} onRenameBlock={onRenameBlock} onDuplicateBlock={onDuplicateBlock} onDeleteBlock={onDeleteBlock} onSvgImport={onSvgImport} onSaveGroupAsBlock={onSaveGroupAsBlock} />}
</div>
<div className={`rightbar-panel${activePanel === 'ki' ? ' active' : ''}`} data-panel-content="ki">
{activePanel === 'ki' && <KICopilot messages={kiMessages ?? []} suggestions={kiSuggestions ?? []} onSend={onKISend ?? (() => {})} onSuggestionClick={onKISuggestionClick ?? (() => {})} loading={kiLoading} />}
</div>
<div className={`rightbar-panel${activePanel === 'plugins' ? ' active' : ''}`} data-panel-content="plugins">
{activePanel === 'plugins' && <PluginManager />}
</div>
</div>
</aside>
);
};
export default RightSidebar;
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import type { StatusBarProps } from '../types/ui.types';
const StatusBar: React.FC<StatusBarProps> = ({
snapEnabled, orthoEnabled, polarEnabled, gridEnabled,
cursorX, cursorY, activeLayer, activeTool, onlineCount,
onToggleSnap, onToggleOrtho, onTogglePolar, onToggleGrid, seatCount,
}) => {
return (
<footer className="statusbar" role="status">
<div className={`status-item${snapEnabled ? ' active' : ''}`} title="Snap-Modus (F3)" aria-pressed={snapEnabled} onClick={onToggleSnap}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
<span>SNAP</span>
</div>
<div className={`status-item${orthoEnabled ? ' active' : ''}`} title="Ortho-Modus (F8)" aria-pressed={orthoEnabled} onClick={onToggleOrtho}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18M3 21l18-18"/></svg>
<span>ORTHO</span>
</div>
<div className={`status-item hide-mobile${polarEnabled ? ' active' : ''}`} title="Polar-Tracking" aria-pressed={polarEnabled} onClick={onTogglePolar}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="2" x2="12" y2="16"/></svg>
<span>POLAR</span>
</div>
<div className={`status-item${gridEnabled ? ' active' : ''}`} title="Grid anzeigen (F7)" aria-pressed={gridEnabled} onClick={onToggleGrid}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<span>GRID</span>
</div>
<div className="status-item" style={{ fontFamily: 'var(--font-mono)' }}>
<span>X: {cursorX.toFixed(3)} m</span>
</div>
<div className="status-item" style={{ fontFamily: 'var(--font-mono)' }}>
<span>Y: {cursorY.toFixed(3)} m</span>
</div>
<div className="status-item hide-mobile" title="Layer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/></svg>
<span>{activeLayer}</span>
</div>
<div className="status-item hide-mobile" title="Werkzeug">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
<span>{activeTool}</span>
</div>
{seatCount !== undefined && seatCount > 0 && (
<div className="status-item hide-mobile" title="Stuhl-Anzahl" style={{ fontFamily: 'var(--font-mono)' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="3" width="12" height="18" rx="1"/><line x1="6" y1="7" x2="18" y2="7"/></svg>
<span>{seatCount} Stühle</span>
</div>
)}
<div className="status-item" title={`${onlineCount} anderer Nutzer online`}>
<span className="status-dot"></span>
<span>Online · {onlineCount} weiterer</span>
</div>
</footer>
);
};
export default StatusBar;
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
import type { TopbarProps } from '../types/ui.types';
const Topbar: React.FC<TopbarProps> = ({ projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme }) => {
return (
<header className="topbar" role="banner">
<div className="topbar-left">
<button className="hamburger-btn" aria-label="Werkzeuge öffnen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div className="app-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
</div>
<span className="app-name">web-cad</span>
<span style={{ color: 'var(--color-border-strong)', margin: '0 4px' }}>|</span>
<button className="project-name" aria-label="Projekt wechseln">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<span>{projectName}</span>
<svg className="caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<span className="saved-badge" aria-label="Status: Alle Änderungen gespeichert">{savedStatus}</span>
</div>
<div className="topbar-right">
<button className="icon-btn-top" aria-label="Rückgängig (Strg+Z)" title="Rückgängig (Strg+Z)" onClick={onUndo}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
</button>
<button className="icon-btn-top" aria-label="Wiederherstellen (Strg+Y)" title="Wiederherstellen (Strg+Y)" onClick={onRedo}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>
</button>
<span style={{ width: '1px', height: '20px', background: 'var(--color-border)', margin: '0 4px' }}></span>
<button className="icon-btn-top" aria-label="Versionen" title="Versionsverlauf">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</button>
<button className="icon-btn-top" aria-label="Teilen" title="Projekt teilen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button>
<span style={{ width: '1px', height: '20px', background: 'var(--color-border)', margin: '0 4px' }}></span>
<button className="icon-btn-top" aria-label="Hell/Dunkel umschalten" title="Theme wechseln" onClick={onThemeToggle}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
</button>
<button className="icon-btn-top" aria-label="Hilfe" title="Hilfe (F1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</button>
<span style={{ width: '1px', height: '20px', background: 'var(--color-border)', margin: '0 4px' }}></span>
<select className="lang-select" aria-label="Sprache wählen" defaultValue="de">
<option value="de">DE</option>
<option value="en">EN</option>
</select>
<button className="icon-btn-top" aria-label="Benachrichtigungen" title="Benachrichtigungen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
</button>
<div className="avatar" aria-label="Benutzer Leopold M." title="Leopold M.">LM</div>
</div>
</header>
);
};
export default Topbar;
+87
View File
@@ -0,0 +1,87 @@
import React, { useState } from 'react';
import type { TreeNode } from '../types/ui.types';
interface TreeViewProps {
nodes: TreeNode[];
selectedId?: string | null;
onSelect: (id: string) => void;
onToggle?: (id: string) => void;
renderIcon?: (node: TreeNode) => React.ReactNode;
renderActions?: (node: TreeNode) => React.ReactNode;
renderDetail?: (node: TreeNode) => React.ReactNode;
draggable?: boolean;
}
const TreeView: React.FC<TreeViewProps> = ({
nodes,
selectedId,
onSelect,
onToggle,
renderIcon,
renderActions,
renderDetail,
draggable = false,
}) => {
const [expandedSet, setExpandedSet] = useState<Set<string>>(new Set());
const toggleExpand = (id: string) => {
setExpandedSet((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
onToggle?.(id);
};
const renderNode = (node: TreeNode, level: number): React.ReactNode => {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedSet.has(node.id) || node.expanded;
const isSelected = selectedId === node.id;
return (
<React.Fragment key={node.id}>
<div
className={`tree-node${isSelected ? ' active' : ''}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => onSelect(node.id)}
draggable={draggable}
>
{hasChildren && (
<button
className="tree-toggle"
onClick={(e) => {
e.stopPropagation();
toggleExpand(node.id);
}}
aria-label={isExpanded ? 'Einklappen' : 'Ausklappen'}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" style={{ transform: isExpanded ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
)}
{!hasChildren && <span className="tree-toggle-placeholder" />}
{renderIcon && <span className="tree-icon">{renderIcon(node)}</span>}
<span className="tree-label">{node.name}</span>
{node.count !== undefined && <span className="tree-count">({node.count})</span>}
{renderActions && <span className="tree-actions" onClick={(e) => e.stopPropagation()}>{renderActions(node)}</span>}
</div>
{renderDetail && isSelected && (
<div className="tree-detail" style={{ paddingLeft: `${level * 16 + 8}px` }}>
{renderDetail(node)}
</div>
)}
{hasChildren && isExpanded && (
<div className="tree-children">
{node.children!.map((child) => renderNode(child, level + 1))}
</div>
)}
</React.Fragment>
);
};
return <div className="tree-view">{nodes.map((node) => renderNode(node, 0))}</div>;
};
export default TreeView;