feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user