feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
.env*
|
||||
.venv
|
||||
__pycache__
|
||||
*.log
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
*.db
|
||||
dist/
|
||||
.env
|
||||
+704
@@ -0,0 +1,704 @@
|
||||
# Bauplan – web-cad-neu
|
||||
|
||||
**Komplett funktionierende 2D-CAD Software für Event-Bestuhlungspläne**
|
||||
Basierend auf: Pflichtenheft v4 (633 Zeilen) + UI-Mockup Iteration 9
|
||||
|
||||
---
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
| Schicht | Technologie | Lizenz |
|
||||
|---------|-------------|--------|
|
||||
| Frontend | React 18 + TypeScript + Vite | MIT |
|
||||
| Rendering | Canvas 2D + rbush (R-Tree) | MIT |
|
||||
| CRDT/Sync | Yjs + y-websocket | MIT |
|
||||
| Backend | Node 20 + TypeScript + Fastify | MIT |
|
||||
| Datenbank | SQLite (better-sqlite3) | Public Domain |
|
||||
| DXF | dxf-parser + dxf-writer | MIT |
|
||||
| PDF | pdf-lib | MIT |
|
||||
| Auth | bcrypt | Apache |
|
||||
| KI | OpenAI-kompatibler Endpoint (user-provided) | – |
|
||||
| Deployment | Docker + Coolify | Open Source |
|
||||
|
||||
---
|
||||
|
||||
## Phasen-Übersicht
|
||||
|
||||
| Phase | Titel | Anforderungen | Status |
|
||||
|-------|-------|---------------|--------|
|
||||
| 0 | Projekt-Setup & Config | – | ✅ Angelegt |
|
||||
| 1 | Core Types & Canvas Engine | F-CAD-01, F-CAD-04 | ⬜ |
|
||||
| 2 | UI-Gerüst nach Mockup | F-UI-01 bis F-UI-08 | ⬜ |
|
||||
| 3 | Zeichen-Tools | F-CAD-01, F-CAD-11, F-CAD-12 | ⬜ |
|
||||
| 4 | Bearbeitungs-Tools | F-CAD-02, F-CAD-07, F-CAD-08 | ⬜ |
|
||||
| 5 | Auswahl & Snap | F-CAD-04, F-CAD-07 | ⬜ |
|
||||
| 6 | Layer-System | F-LAY-01 bis F-LAY-06 | ⬜ |
|
||||
| 7 | Block-Bibliothek | F-LIB-01 bis F-LIB-07 | ⬜ |
|
||||
| 8 | Bestuhlungs-Tools | F-EVT-01 bis F-EVT-05 | ⬜ |
|
||||
| 9 | Bemaßung & Text | F-CAD-03, F-CAD-11 | ⬜ |
|
||||
| 10 | Hintergrund & Grundriss | F-BG-01 bis F-BG-04 | ⬜ |
|
||||
| 11 | Undo/Redo & History | F-CAD-09, F-CAD-10 | ⬜ |
|
||||
| 12 | Command Line | F-CAD-05, F-UI-04 | ⬜ |
|
||||
| 13 | Import/Export | F-IMP-01 bis F-IMP-04, F-EXP-01 bis F-EXP-04 | ⬜ |
|
||||
| 14 | Backend & REST-API | F-INT-01, F-INT-02, F-INT-04 | ⬜ |
|
||||
| 15 | Auth & Benutzerverwaltung | F-AUTH-01 bis F-AUTH-05 | ⬜ |
|
||||
| 16 | Multi-User & Kollaboration | F-MU-01 bis F-MU-07 | ⬜ |
|
||||
| 17 | KI Copilot | F-AI-01 bis F-AI-15 | ⬜ |
|
||||
| 18 | Plugin-System | F-EXT-01 bis F-EXT-08 | ⬜ |
|
||||
| 19 | Theme & Responsive | F-UI-05, F-UI-06, F-UI-10 | ⬜ |
|
||||
| 20 | Docker & Deployment | NF-DEP-01 bis NF-DEP-05 | ⬜ |
|
||||
| 21 | Tests & QA | NF-PERF-01 bis NF-PERF-05 | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Projekt-Setup & Config ✅
|
||||
|
||||
**Ziel:** Saubere Projektstruktur, Config-Dateien, keine Duplikate
|
||||
|
||||
**Dateien:**
|
||||
- `package.json` (root, frontend, backend)
|
||||
- `tsconfig.json` (frontend, backend)
|
||||
- `vite.config.ts`
|
||||
- `docker-compose.yml` (eine Datei, nicht 5)
|
||||
- `Dockerfile` (frontend, backend)
|
||||
- `nginx.conf`
|
||||
- `.gitignore`
|
||||
- `README.md`
|
||||
- `frontend/index.html`
|
||||
- `frontend/src/types/cad.types.ts` – Alle Core Types
|
||||
|
||||
**Erledigt:** ✅ Struktur, Config, Types, Canvas Core (SpatialIndex, ZoomPanController, LayerManager)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Core Types & Canvas Engine
|
||||
|
||||
**Anforderungen:** F-CAD-01 (Grundelemente), F-CAD-04 (Raster & Fang)
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/canvas/RenderEngine.ts` – Haupt-Renderer mit Canvas 2D, Viewport-Culling, Layer-basiertem Rendering, Grid
|
||||
- `frontend/src/canvas/SpatialIndex.ts` – ✅ Angelegt (rbush R-Tree)
|
||||
- `frontend/src/canvas/ZoomPanController.ts` – ✅ Angelegt (Zoom, Pan, screenToWorld, worldToScreen, zoomFit)
|
||||
- `frontend/src/canvas/LayerManager.ts` – ✅ Angelegt (CRUD, Visibility, Lock, Active Layer)
|
||||
- `frontend/src/interaction/SnapEngine.ts` – Snap-to-Grid, Endpoint, Midpoint, Intersection, Ortho, Polar
|
||||
- `frontend/src/interaction/SelectionEngine.ts` – Einzelauswahl, Fenster-Auswahl, Kreuz-Auswahl
|
||||
|
||||
**RenderEngine muss:**
|
||||
- Canvas 2D Context verwalten
|
||||
- requestAnimationFrame Render-Loop
|
||||
- Zoom/Pan Transform anwenden
|
||||
- Grid rendern (minor + major lines)
|
||||
- Viewport-Culling via SpatialIndex
|
||||
- Elemente nach Layer-Reihenfolge rendern
|
||||
- Element-Typen rendern: line, circle, arc, rect, polygon, polyline, text, dimension, block_instance, chair
|
||||
- Selection-Highlights rendern
|
||||
- Snap-Points visuell hervorheben
|
||||
- 50.000+ Elemente bei 60fps
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: UI-Gerüst nach Mockup
|
||||
|
||||
**Anforderungen:** F-UI-01 bis F-UI-08
|
||||
|
||||
**Referenz:** `/a0/usr/workdir/web-cad-neu/mockup/editor.html` (Iteration 9)
|
||||
|
||||
**Komponenten:**
|
||||
- `frontend/src/App.tsx` – Root, ThemeProvider, Layout-Container
|
||||
- `frontend/src/main.tsx` – React Entry Point
|
||||
- `frontend/src/styles/theme.css` – Design Tokens (CSS Custom Properties), Dark/Light Theme
|
||||
- `frontend/src/styles/layout.css` – Layout-Regeln aus Mockup layout.css (1298 Zeilen)
|
||||
- `frontend/src/styles/styles.css` – Base Styles aus Mockup styles.css (195 Zeilen)
|
||||
- `frontend/src/components/Topbar.tsx` – Logo, Projektname, Saved-Badge, Undo/Redo, Version, Share, Theme, Hilfe, Sprache, Avatar
|
||||
- `frontend/src/components/RibbonBar.tsx` – 6 Tabs (Start, Einfügen, Format, Ansicht, Extras, KI) mit Ribbon-Groups
|
||||
- `frontend/src/components/LeftSidebar.tsx` – 4 Sektionen (Auswählen, Zeichnen, Bearbeiten, Bestuhlung), 20 Tools als Buttons mit SVG-Icons
|
||||
- `frontend/src/components/CanvasArea.tsx` – Canvas-Container, Koordinaten-Display, View-Tabs (2D/ISO/Front/Top)
|
||||
- `frontend/src/components/RightSidebar.tsx` – 4 Tabs: Properties, Layer (Tree), Bibliothek (Tree), KI Copilot
|
||||
- `frontend/src/components/PropertiesPanel/PropertiesPanel.tsx` – Geometrie, Darstellung, Block-Info
|
||||
- `frontend/src/components/LayerPanel.tsx` – Tree-Struktur mit Ordnern, Visibility-Toggle, Lock-Toggle, Add-Button
|
||||
- `frontend/src/components/BlockLibrary.tsx` – Tree-Struktur, Suchfeld, Kategorie-Filter, Draggable Leafs
|
||||
- `frontend/src/components/KICopilot.tsx` – Chat-UI, Schnell-Aktionen, Voice-Input, Send-Button
|
||||
- `frontend/src/components/CommandLine.tsx` – History + Input mit Autovervollständigung
|
||||
- `frontend/src/components/StatusBar.tsx` – SNAP/ORTHO/POLAR/GRID, Koordinaten, Layer, Werkzeug, Online
|
||||
- `frontend/src/components/TreeView.tsx` – Generische Tree-Komponente (collapsible, draggable)
|
||||
- `frontend/src/components/MobileDrawers.tsx` – Hamburger-Drawer + Right-Tab-Bar + Slide-in (≤768px)
|
||||
|
||||
**UI muss exakt dem Mockup entsprechen:**
|
||||
- Topbar → Ribbon → (LeftSidebar | CanvasArea | RightSidebar) → CommandLine → StatusBar
|
||||
- Inter/JetBrains Mono Fonts
|
||||
- CSS Custom Properties für Theme
|
||||
- SVG Icons aus Mockup übernehmen
|
||||
- Responsive: Desktop ≥1280px, Mobile Drawers ≤768px
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Zeichen-Tools
|
||||
|
||||
**Anforderungen:** F-CAD-01, F-CAD-11, F-CAD-12
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/tools/Tool.ts` – Base Tool Interface (onMouseDown, onMouseMove, onMouseUp, onKeyDown, render)
|
||||
- `frontend/src/tools/ToolManager.ts` – Active Tool, Tool-Switching, Tool-Events
|
||||
- `frontend/src/tools/drawing/LineTool.ts` – Linie (L): 2 Punkte → Linie
|
||||
- `frontend/src/tools/drawing/PolylineTool.ts` – Polylinie (PL): Multiple Punkte → Polyline
|
||||
- `frontend/src/tools/drawing/RectTool.ts` – Rechteck (REC): 2 Punkte → Rechteck
|
||||
- `frontend/src/tools/drawing/CircleTool.ts` – Kreis (C): Center + Radius
|
||||
- `frontend/src/tools/drawing/ArcTool.ts` – Bogen (A): Center + StartAngle + EndAngle
|
||||
- `frontend/src/tools/drawing/PolygonTool.ts` – Polygon: Multiple Punkte → Geschlossen
|
||||
- `frontend/src/tools/drawing/TextTool.ts` – Text (T): Position + Text + FontSize
|
||||
- `frontend/src/tools/drawing/DimensionTool.ts` – Bemaßung (DIM): 2 Punkte + Maßlinie
|
||||
- `frontend/src/tools/drawing/HatchTool.ts` – Schraffur (H): Fläche + Muster
|
||||
- `frontend/src/tools/drawing/index.ts` – Export aller Tools
|
||||
|
||||
**Jedes Tool:**
|
||||
- Mouse-Events (down/move/up) verarbeiten
|
||||
- Snap-Engine nutzen
|
||||
- Preview während des Zeichnens
|
||||
- Element auf aktiver Ebene erstellen
|
||||
- Undo-Historie aktualisieren
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Bearbeitungs-Tools
|
||||
|
||||
**Anforderungen:** F-CAD-02, F-CAD-07, F-CAD-08
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/tools/modification/MoveTool.ts` – Verschieben (M): Basispunkt + Ziel
|
||||
- `frontend/src/tools/modification/CopyTool.ts` – Kopieren (CO): Basispunkt + Ziel
|
||||
- `frontend/src/tools/modification/RotateTool.ts` – Rotieren (RO): Basispunkt + Winkel
|
||||
- `frontend/src/tools/modification/ScaleTool.ts` – Skalieren (SC): Basispunkt + Faktor
|
||||
- `frontend/src/tools/modification/MirrorTool.ts` – Spiegeln (MI): Spiegelachse
|
||||
- `frontend/src/tools/modification/TrimTool.ts` – Trimmen (TR): Schneiden an Grenzelementen
|
||||
- `frontend/src/tools/modification/ExtendTool.ts` – Verlängern (EX): Bis zu Grenzelement
|
||||
- `frontend/src/tools/modification/FilletTool.ts` – Abrunden (F): Radius + 2 Elemente
|
||||
- `frontend/src/tools/modification/OffsetTool.ts` – Versatz (O): Parallelverschiebung
|
||||
- `frontend/src/tools/modification/DeleteTool.ts` – Löschen (E)
|
||||
- `frontend/src/tools/modification/index.ts` – Export
|
||||
|
||||
**Gruppierung:**
|
||||
- `frontend/src/tools/GroupTool.ts` – Gruppe erstellen/auflösen
|
||||
- Gruppen können verschachtelt werden
|
||||
- Gruppen in Bibliothek speicherbar
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Auswahl & Snap
|
||||
|
||||
**Anforderungen:** F-CAD-04, F-CAD-07
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/interaction/SelectionEngine.ts` – Einzelauswahl (Klick), Fenster-Auswahl (Links-Rechts), Kreuz-Auswahl (Rechts-Links), Quick-Select (Eigenschafts-basiert)
|
||||
- `frontend/src/interaction/SnapEngine.ts` – Snap-to-Grid, Endpoint, Midpoint, Intersection, Ortho-Modus, Polar-Tracking
|
||||
- `frontend/src/interaction/index.ts` – Export
|
||||
|
||||
**Snap-Engine:**
|
||||
- Snap-Modi umschaltbar (F3)
|
||||
- Visuelle Hervorhebung der Snap-Punkte
|
||||
- Ortho-Modus (F8): Nur horizontal/vertikal
|
||||
- Polar-Tracking: Winkel-Snaps (0°, 30°, 45°, 60°, 90°)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Layer-System
|
||||
|
||||
**Anforderungen:** F-LAY-01 bis F-LAY-06
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/canvas/LayerManager.ts` – ✅ Angelegt, erweitern um: Baumstruktur (parentId), Filter, Duplizieren
|
||||
- `frontend/src/components/LayerPanel.tsx` – Tree-Widget mit Parent-Child, Visibility/Lock/Color/LineType/Transparency pro Ebene
|
||||
- `frontend/src/components/TreeView.tsx` – Generische Tree-Komponente
|
||||
|
||||
**Funktionen:**
|
||||
- Ebenen als Baumstruktur (Parent-Child)
|
||||
- Eigenschaften pro Ebene: Name, Visibility, Lock, Color, LineType, Transparency
|
||||
- Operationen: Erstellen, Löschen, Umbenennen, Verschieben im Baum, Duplizieren
|
||||
- Element-Zuordnung: Drag-and-Drop zwischen Ebenen
|
||||
- Aktive Ebene markieren
|
||||
- Ebenen-Filter nach Eigenschaften
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Block-Bibliothek
|
||||
|
||||
**Anforderungen:** F-LIB-01 bis F-LIB-07
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/components/BlockLibrary.tsx` – Tree mit Ordnern, Suchfeld, Kategorie-Filter, Draggable Leafs
|
||||
- `frontend/src/services/blockService.ts` – Block CRUD, SVG-Import, Block-Definition vs. Referenz
|
||||
- `frontend/src/types/cad.types.ts` – BlockDefinition Type ✅
|
||||
|
||||
**Funktionen:**
|
||||
- Bibliothek als Baumstruktur (Ordner + Unterordner)
|
||||
- SVG-Import in Bibliothek
|
||||
- Gruppen als Blöcke speichern
|
||||
- Drag-and-Drop in Zeichenbereich
|
||||
- Block-Bearbeitung (Skalieren, Rotieren, Spiegeln)
|
||||
- Block-Verwaltung (Umbenennen, Duplizieren, Löschen, Verschieben)
|
||||
- Block-Definition vs. Referenz (Änderung an Definition → alle Instanzen aktualisieren)
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Bestuhlungs-Tools
|
||||
|
||||
**Anforderungen:** F-EVT-01 bis F-EVT-05
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/tools/drawing/ChairTool.ts` – Einzelner Stuhl
|
||||
- `frontend/src/tools/drawing/SeatingRowTool.ts` – Reihen-Bestuhlung (R): Reihe mit Anzahl, Abstand, Ausrichtung
|
||||
- `frontend/src/tools/drawing/SeatingBlockTool.ts` – Block-Bestuhlung (B): Rechteck-Bereich mit Reihen, Spalten, Abständen
|
||||
- `frontend/src/tools/drawing/TableTool.ts` – Tisch (TAB)
|
||||
- `frontend/src/tools/drawing/StageTool.ts` – Bühne (S)
|
||||
- `frontend/src/tools/drawing/SeatingTemplateTool.ts` – Bestuhlungs-Vorlagen
|
||||
|
||||
**Funktionen:**
|
||||
- Reihen-Bestuhlung: Anzahl, Abstand, Ausrichtung konfigurierbar
|
||||
- Block-Bestuhlung: Reihen × Spalten, Abstände, Versatz
|
||||
- Stuhl-Typ aus Bibliothek wählbar
|
||||
- Parameter im Nachhinein änderbar
|
||||
- Einzelne Stühle hinzufügen/entfernen/verschieben
|
||||
- Automatische Zählung (pro Reihe, Block, Gesamt)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Bemaßung & Text
|
||||
|
||||
**Anforderungen:** F-CAD-03, F-CAD-11
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/tools/drawing/DimensionTool.ts` – Lineare, winkelige, radiale Bemaßung
|
||||
- `frontend/src/tools/drawing/TextTool.ts` – Einzel- und Mehrzeilentext
|
||||
- `frontend/src/tools/drawing/LeaderTool.ts` – Leaders (MLEADER)
|
||||
- `frontend/src/tools/drawing/RevCloudTool.ts` – Revision Clouds (REVCLOUD)
|
||||
|
||||
**Bemaßung:**
|
||||
- Maßberechnung basierend auf Hintergrund-Maßstab
|
||||
- Werte in realen Maßeinheiten (m/cm)
|
||||
- Lineare, winkelige, radiale Bemaßung
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Hintergrund & Grundriss
|
||||
|
||||
**Anforderungen:** F-BG-01 bis F-BG-04
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/services/backgroundService.ts` – Laden, Positionieren, Skalieren von Hintergrundbildern
|
||||
- `frontend/src/components/BackgroundImport.tsx` – Upload-Dialog
|
||||
|
||||
**Funktionen:**
|
||||
- PNG, JPG, SVG, PDF-Seite als Hintergrund laden
|
||||
- Maßstab definieren (1:100, Referenzstrecke)
|
||||
- Hintergrund verschieben, rotieren, skalieren
|
||||
- Sichtbarkeit ein/aus
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Undo/Redo & History
|
||||
|
||||
**Anforderungen:** F-CAD-09, F-CAD-10
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/history/HistoryManager.ts` – Undo/Redo Stack mit beliebig vielen Schritten
|
||||
- `frontend/src/history/index.ts` – Export
|
||||
|
||||
**Funktionen:**
|
||||
- Jede Operation erzeugt History-Eintrag
|
||||
- Undo (Strg+Z) / Redo (Strg+Y)
|
||||
- Historie einsehbar
|
||||
- Kopieren zwischen Dateien via Zwischenablage
|
||||
|
||||
---
|
||||
|
||||
## Phase 12: Command Line
|
||||
|
||||
**Anforderungen:** F-CAD-05, F-UI-04
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/components/CommandLine.tsx` – History + Input mit Autovervollständigung
|
||||
- `frontend/src/services/commandRegistry.ts` – Befehls-Registry (L, PL, C, REC, M, CO, RO, SC, MI, TR, O, DIM, H, BESTUHLUNG, etc.)
|
||||
|
||||
**Funktionen:**
|
||||
- Befehlseingabe via Tastatur (AutoCAD-Style)
|
||||
- Autovervollständigung
|
||||
- Befehls-History
|
||||
- Tastatur-Shortcuts (L, C, M, CO, RO, SC, MI, PL, H, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 13: Import/Export
|
||||
|
||||
**Anforderungen:** F-IMP-01 bis F-IMP-04, F-EXP-01 bis F-EXP-04
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/services/importService.ts` – DXF, SVG, PDF, JSON Import
|
||||
- `frontend/src/services/exportService.ts` – DXF, SVG, PDF, PNG, JSON Export
|
||||
- `frontend/src/services/dxfParser.ts` – DXF Parsing (dxf-parser library)
|
||||
- `frontend/src/services/dxfWriter.ts` – DXF Writing
|
||||
- `frontend/src/services/pdfExport.ts` – PDF mit pdf-lib
|
||||
|
||||
**Formate:**
|
||||
- Import: DXF, SVG, PDF (optional DWG via libredwg), JSON
|
||||
- Export: DXF, SVG, PDF, PNG, JSON
|
||||
- JSON enthält alle Daten (Layers, Elements, Blocks, Background)
|
||||
|
||||
---
|
||||
|
||||
## Phase 14: Backend & REST-API
|
||||
|
||||
**Anforderungen:** F-INT-01, F-INT-02, F-INT-04, F-INT-07
|
||||
|
||||
**Dateien:**
|
||||
- `backend/src/index.ts` – Fastify Server Start
|
||||
- `backend/src/server.ts` – Server-Konfiguration (CORS, Static, WebSocket)
|
||||
- `backend/src/database/DatabaseInterface.ts` – Abstrahierte DB-Schicht
|
||||
- `backend/src/database/SqliteAdapter.ts` – SQLite Implementation
|
||||
- `backend/src/database/schema.sql` – Schema: users, projects, drawings, blocks, layers, elements, settings
|
||||
- `backend/src/database/migrations.ts` – Migration-Runner
|
||||
- `backend/src/routes/index.ts` – Route-Registration
|
||||
- `backend/src/routes/projects.ts` – CRUD für Projekte
|
||||
- `backend/src/routes/drawings.ts` – CRUD für Zeichnungen
|
||||
- `backend/src/routes/layers.ts` – CRUD für Layer
|
||||
- `backend/src/routes/elements.ts` – CRUD für Elemente
|
||||
- `backend/src/routes/blocks.ts` – CRUD für Blöcke
|
||||
- `backend/src/routes/export.ts` – Export-Endpunkte
|
||||
- `backend/src/routes/import.ts` – Import-Endpunkte
|
||||
- `backend/src/routes/settings.ts` – App-Settings (KI-Config, etc.)
|
||||
- `backend/src/routes/users.ts` – User-Verwaltung
|
||||
|
||||
**API-First:**
|
||||
- Alle Funktionen über REST-API erreichbar
|
||||
- OpenAPI/Swagger Dokumentation
|
||||
- Headless-Modus (nur API, keine UI)
|
||||
- Webhook-Support
|
||||
|
||||
---
|
||||
|
||||
## Phase 15: Auth & Benutzerverwaltung
|
||||
|
||||
**Anforderungen:** F-AUTH-01 bis F-AUTH-05, NF-SEC-01, NF-SEC-02
|
||||
|
||||
**Dateien:**
|
||||
- `backend/src/auth/AuthService.ts` – Register, Login, Password-Reset, Session-Management
|
||||
- `backend/src/routes/auth.ts` – Auth-Endpunkte
|
||||
- `frontend/src/contexts/AuthContext.tsx` – Auth-State Management
|
||||
- `frontend/src/pages/Login.tsx` – Login-Seite
|
||||
- `frontend/src/pages/Register.tsx` – Registrierung
|
||||
- `frontend/src/pages/Dashboard.tsx` – Projekt-Übersicht
|
||||
|
||||
**Funktionen:**
|
||||
- E-Mail/Passwort-Login (bcrypt)
|
||||
- Passwort-Reset via E-Mail
|
||||
- Session-Management (HTTP-only Cookies, CSRF-Schutz)
|
||||
- Rollen: Planer, Betrachter, Admin, Gast
|
||||
- Gast-Zugänge für spezifische Pläne
|
||||
|
||||
---
|
||||
|
||||
## Phase 16: Multi-User & Kollaboration
|
||||
|
||||
**Anforderungen:** F-MU-01 bis F-MU-07
|
||||
|
||||
**Dateien:**
|
||||
- `backend/src/websocket/yjsServer.ts` – Yjs WebSocket Server mit Persistenz
|
||||
- `frontend/src/crdt/YjsDocument.ts` – Yjs Document Wrapper
|
||||
- `frontend/src/crdt/WebSocketProvider.ts` – WebSocket-Verbindung
|
||||
- `frontend/src/crdt/AwarenessManager.ts` – Nutzer-Anwesenheit, Cursor-Positionen
|
||||
- `frontend/src/crdt/useYjsBinding.ts` – React Hook für Yjs-Binding
|
||||
|
||||
**Funktionen:**
|
||||
- Echtzeit-Kollaboration (Yjs CRDT)
|
||||
- Konfliktfreie Bearbeitung
|
||||
- Nutzer-Anwesenheit (Avatar/Farbe)
|
||||
- Cursor-Anzeige anderer Nutzer
|
||||
- Berechtigungs-System (Rollen)
|
||||
- Offline-Unterstützung (IndexedDB)
|
||||
- Versionshistorie
|
||||
|
||||
---
|
||||
|
||||
## Phase 17: KI Copilot
|
||||
|
||||
**Anforderungen:** F-AI-01 bis F-AI-15
|
||||
|
||||
**Dateien:**
|
||||
- `backend/src/routes/ai.ts` – KI-Proxy-Endpoint (leitet an OpenAI-kompatiblen API weiter)
|
||||
- `frontend/src/services/aiService.ts` – KI-Client (sendet an Backend, Backend leitet weiter)
|
||||
- `frontend/src/services/functionRegistry.ts` – CAD-Function-Registry (alle CAD-Operationen als Functions)
|
||||
- `frontend/src/components/KICopilot.tsx` – Chat-UI, Schnell-Aktionen, Voice-Input
|
||||
|
||||
**Architektur:**
|
||||
```
|
||||
User Input → Backend → OpenAI-kompatibler API-Endpoint
|
||||
→ LLM generiert Function Call (z.B. draw_line(x1,y1,x2,y2))
|
||||
→ Function-Registry führt Function aus
|
||||
→ Canvas aktualisiert sich
|
||||
→ Ergebnis an LLM → Bestätigung an User
|
||||
```
|
||||
|
||||
**Functions (registriert):**
|
||||
- draw_line, draw_circle, draw_rect, draw_arc, draw_polyline, draw_text, draw_dimension
|
||||
- move, copy, rotate, scale, mirror, trim, offset, delete
|
||||
- create_layer, delete_layer, set_active_layer, toggle_layer_visibility
|
||||
- insert_block, create_block, delete_block
|
||||
- seating_row, seating_block, count_seats
|
||||
- export_dxf, export_svg, export_pdf, import_dxf, import_svg
|
||||
- undo, redo
|
||||
- get_context (aktuelle Zeichnung, Selektion, Layer, Zoom)
|
||||
|
||||
**Guardrails:**
|
||||
- Destruktive Operationen erfordern Bestätigung
|
||||
- Function-Validation vor Ausführung
|
||||
- Safety-Checks (nicht "Lösche alles" ohne Bestätigung)
|
||||
|
||||
**Context Injection:**
|
||||
- Aktuelle Zeichnung (Summary, nicht alle Elemente)
|
||||
- Selektion
|
||||
- Aktive Ebene
|
||||
- Zoom-Bereich
|
||||
- Maßstab
|
||||
|
||||
---
|
||||
|
||||
## Phase 18: Plugin-System
|
||||
|
||||
**Anforderungen:** F-EXT-01 bis F-EXT-08
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/services/pluginRegistry.ts` – Plugin-Registration, Lifecycle, Isolation
|
||||
- `frontend/src/types/plugin.types.ts` – Plugin-Manifest, Tool-API Interface
|
||||
- `frontend/src/components/PluginSettings.tsx` – Plugin-Verwaltung UI
|
||||
|
||||
**Funktionen:**
|
||||
- Plugin-Manifest (Name, Version, Dependencies)
|
||||
- Tool-API (Zeichnen, Bearbeiten, Bibliothek, Export, UI)
|
||||
- Plugin-Isolation (try-catch, kein Core-Crash)
|
||||
- Aktivieren/Deaktivieren ohne Neustart
|
||||
- Plugin-UI-Integration (Ribbon-Tabs, Panels, Dialoge)
|
||||
- Laufzeit-Laden
|
||||
|
||||
---
|
||||
|
||||
## Phase 19: Theme & Responsive
|
||||
|
||||
**Anforderungen:** F-UI-05, F-UI-06, F-UI-10
|
||||
|
||||
**Dateien:**
|
||||
- `frontend/src/contexts/ThemeContext.tsx` – Dark/Light Theme Toggle
|
||||
- `frontend/src/styles/theme.css` – CSS Custom Properties für beide Themes
|
||||
- Mobile Drawers aus Mockup übernehmen
|
||||
|
||||
**Funktionen:**
|
||||
- Dark- und Light-Mode
|
||||
- Responsive: Desktop ≥1280px (alle Panels)
|
||||
- Mobile ≤768px: Hamburger-Drawer + Right-Tab-Bar + Slide-in Drawers
|
||||
- Voice Input (Web Speech API, optional)
|
||||
|
||||
---
|
||||
|
||||
## Phase 20: Docker & Deployment
|
||||
|
||||
**Anforderungen:** NF-DEP-01 bis NF-DEP-05
|
||||
|
||||
**Dateien:**
|
||||
- `docker-compose.yml` – ✅ Angelegt (frontend + backend)
|
||||
- `frontend/Dockerfile` – ✅ Angelegt (Node build + nginx)
|
||||
- `backend/Dockerfile` – ✅ Angelegt (Node + tsx)
|
||||
- `docker-compose.coolify.yml` – Coolify-spezifische Config
|
||||
|
||||
**Deployment:**
|
||||
- 2 Container: Frontend (nginx) + Backend (Node)
|
||||
- SQLite als Volume
|
||||
- WebSocket-Proxy via Traefik
|
||||
- SSL via Let's Encrypt
|
||||
- Env-Variablen: API_URL, API_KEY, DB_PATH, JWT_SECRET
|
||||
- Coolify-kompatibel
|
||||
|
||||
---
|
||||
|
||||
## Phase 21: Tests & QA
|
||||
|
||||
**Anforderungen:** NF-PERF-01 bis NF-PERF-05, NF-SEC-01 bis NF-SEC-05
|
||||
|
||||
**Tests:**
|
||||
- `frontend/src/tools/__tests__/` – Tool-Tests (Zeichnen, Bearbeiten)
|
||||
- `backend/src/routes/__tests__/` – API-Tests
|
||||
- `backend/src/auth/__tests__/` – Auth-Tests
|
||||
- Performance-Benchmark: 50.000 Elemente bei 60fps
|
||||
- Lighthouse-Score ≥ 80
|
||||
- Security-Tests: Input-Validierung, CSRF, Auth
|
||||
|
||||
---
|
||||
|
||||
## Build-Reihenfolge
|
||||
|
||||
```
|
||||
Phase 0 ✅ Setup
|
||||
Phase 1 ⬜ Canvas Engine (RenderEngine, SnapEngine, SelectionEngine)
|
||||
Phase 2 ⬜ UI-Gerüst (alle Komponenten nach Mockup)
|
||||
Phase 3 ⬜ Zeichen-Tools (Line, Circle, Rect, Arc, Polyline, Polygon, Text, Dimension, Hatch)
|
||||
Phase 4 ⬜ Bearbeitungs-Tools (Move, Copy, Rotate, Scale, Mirror, Trim, Extend, Fillet, Offset, Delete)
|
||||
Phase 5 ⬜ Auswahl & Snap (Selection, Snap-Modes, Ortho, Polar)
|
||||
Phase 6 ⬜ Layer-System (Tree, Eigenschaften, Operationen)
|
||||
Phase 7 ⬜ Block-Bibliothek (Tree, SVG-Import, Drag-and-Drop, Definition/Referenz)
|
||||
Phase 8 ⬜ Bestuhlungs-Tools (Reihe, Block, Tisch, Bühne, Zählung)
|
||||
Phase 9 ⬜ Bemaßung & Text (Linear, Winkel, Radial, MTEXT, Leader, RevCloud)
|
||||
Phase 10 ⬜ Hintergrund (Laden, Maßstab, Positionieren)
|
||||
Phase 11 ⬜ Undo/Redo (History-Stack, Zwischenablage)
|
||||
Phase 12 ⬜ Command Line (Befehlseingabe, Autovervollständigung, Shortcuts)
|
||||
Phase 13 ⬜ Import/Export (DXF, SVG, PDF, PNG, JSON)
|
||||
Phase 14 ⬜ Backend & REST-API (Fastify, SQLite, CRUD, OpenAPI)
|
||||
Phase 15 ⬜ Auth (Login, Register, Rollen, Sessions)
|
||||
Phase 16 ⬜ Multi-User (Yjs, WebSocket, Cursor, Awareness, Offline)
|
||||
Phase 17 ⬜ KI Copilot (Function Calling, Context, Guardrails)
|
||||
Phase 18 ⬜ Plugin-System (Manifest, API, Isolation, UI)
|
||||
Phase 19 ⬜ Theme & Responsive (Dark/Light, Mobile Drawers)
|
||||
Phase 20 ⬜ Docker & Deployment (Coolify, SSL, Env)
|
||||
Phase 21 ⬜ Tests & QA (Unit, Integration, Performance, Security)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verzeichnis-Struktur (Final)
|
||||
|
||||
```
|
||||
web-cad-neu/
|
||||
├── package.json
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.coolify.yml
|
||||
├── .gitignore
|
||||
├── README.md
|
||||
├── BAUPLAN.md
|
||||
├── mockup/ # UI-Referenz (Iteration 9)
|
||||
│ ├── editor.html
|
||||
│ └── assets/
|
||||
├── frontend/
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── vite.config.ts
|
||||
│ ├── Dockerfile
|
||||
│ ├── nginx.conf
|
||||
│ ├── index.html
|
||||
│ └── src/
|
||||
│ ├── main.tsx
|
||||
│ ├── App.tsx
|
||||
│ ├── types/
|
||||
│ │ ├── cad.types.ts
|
||||
│ │ └── plugin.types.ts
|
||||
│ ├── canvas/
|
||||
│ │ ├── RenderEngine.ts
|
||||
│ │ ├── SpatialIndex.ts
|
||||
│ │ ├── ZoomPanController.ts
|
||||
│ │ └── LayerManager.ts
|
||||
│ ├── interaction/
|
||||
│ │ ├── SnapEngine.ts
|
||||
│ │ ├── SelectionEngine.ts
|
||||
│ │ └── index.ts
|
||||
│ ├── tools/
|
||||
│ │ ├── Tool.ts
|
||||
│ │ ├── ToolManager.ts
|
||||
│ │ ├── drawing/
|
||||
│ │ │ ├── LineTool.ts
|
||||
│ │ │ ├── PolylineTool.ts
|
||||
│ │ │ ├── RectTool.ts
|
||||
│ │ │ ├── CircleTool.ts
|
||||
│ │ │ ├── ArcTool.ts
|
||||
│ │ │ ├── PolygonTool.ts
|
||||
│ │ │ ├── TextTool.ts
|
||||
│ │ │ ├── DimensionTool.ts
|
||||
│ │ │ ├── HatchTool.ts
|
||||
│ │ │ ├── ChairTool.ts
|
||||
│ │ │ ├── SeatingRowTool.ts
|
||||
│ │ │ ├── SeatingBlockTool.ts
|
||||
│ │ │ ├── TableTool.ts
|
||||
│ │ │ ├── StageTool.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── modification/
|
||||
│ │ ├── MoveTool.ts
|
||||
│ │ ├── CopyTool.ts
|
||||
│ │ ├── RotateTool.ts
|
||||
│ │ ├── ScaleTool.ts
|
||||
│ │ ├── MirrorTool.ts
|
||||
│ │ ├── TrimTool.ts
|
||||
│ │ ├── ExtendTool.ts
|
||||
│ │ ├── FilletTool.ts
|
||||
│ │ ├── OffsetTool.ts
|
||||
│ │ ├── DeleteTool.ts
|
||||
│ │ └── index.ts
|
||||
│ ├── components/
|
||||
│ │ ├── Topbar.tsx
|
||||
│ │ ├── RibbonBar.tsx
|
||||
│ │ ├── LeftSidebar.tsx
|
||||
│ │ ├── CanvasArea.tsx
|
||||
│ │ ├── RightSidebar.tsx
|
||||
│ │ ├── CommandLine.tsx
|
||||
│ │ ├── StatusBar.tsx
|
||||
│ │ ├── LayerPanel.tsx
|
||||
│ │ ├── BlockLibrary.tsx
|
||||
│ │ ├── KICopilot.tsx
|
||||
│ │ ├── TreeView.tsx
|
||||
│ │ ├── MobileDrawers.tsx
|
||||
│ │ ├── PropertiesPanel/
|
||||
│ │ │ └── PropertiesPanel.tsx
|
||||
│ │ └── pages/
|
||||
│ │ ├── Login.tsx
|
||||
│ │ ├── Register.tsx
|
||||
│ │ └── Dashboard.tsx
|
||||
│ ├── services/
|
||||
│ │ ├── api.ts
|
||||
│ │ ├── blockService.ts
|
||||
│ │ ├── importService.ts
|
||||
│ │ ├── exportService.ts
|
||||
│ │ ├── backgroundService.ts
|
||||
│ │ ├── aiService.ts
|
||||
│ │ ├── functionRegistry.ts
|
||||
│ │ ├── commandRegistry.ts
|
||||
│ │ └── pluginRegistry.ts
|
||||
│ ├── history/
|
||||
│ │ ├── HistoryManager.ts
|
||||
│ │ └── index.ts
|
||||
│ ├── crdt/
|
||||
│ │ ├── YjsDocument.ts
|
||||
│ │ ├── WebSocketProvider.ts
|
||||
│ │ ├── AwarenessManager.ts
|
||||
│ │ └── useYjsBinding.ts
|
||||
│ ├── contexts/
|
||||
│ │ ├── AuthContext.tsx
|
||||
│ │ └── ThemeContext.tsx
|
||||
│ └── styles/
|
||||
│ ├── theme.css
|
||||
│ ├── layout.css
|
||||
│ └── styles.css
|
||||
├── backend/
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── Dockerfile
|
||||
│ └── src/
|
||||
│ ├── index.ts
|
||||
│ ├── server.ts
|
||||
│ ├── auth/
|
||||
│ │ ├── AuthService.ts
|
||||
│ │ └── authPlugin.ts
|
||||
│ ├── database/
|
||||
│ │ ├── DatabaseInterface.ts
|
||||
│ │ ├── SqliteAdapter.ts
|
||||
│ │ ├── schema.sql
|
||||
│ │ └── migrations.ts
|
||||
│ ├── routes/
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── auth.ts
|
||||
│ │ ├── projects.ts
|
||||
│ │ ├── drawings.ts
|
||||
│ │ ├── layers.ts
|
||||
│ │ ├── elements.ts
|
||||
│ │ ├── blocks.ts
|
||||
│ │ ├── export.ts
|
||||
│ │ ├── import.ts
|
||||
│ │ ├── settings.ts
|
||||
│ │ ├── users.ts
|
||||
│ │ └── ai.ts
|
||||
│ └── websocket/
|
||||
│ └── yjsServer.ts
|
||||
└── specs/
|
||||
└── requirements.md # Kopie des Pflichtenhefts
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
# Web CAD Neu
|
||||
|
||||
Web-basiertes 2D-CAD für Event-Bestuhlungspläne.
|
||||
|
||||
## Stack
|
||||
- Frontend: React + TypeScript + Vite + HTML Canvas
|
||||
- Backend: Node.js + TypeScript + Fastify + SQLite
|
||||
- Collaboration: Yjs CRDT
|
||||
|
||||
## Entwicklung
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend: http://localhost:3001
|
||||
|
||||
## Docker
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
*.log
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache wget
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/index.js"]
|
||||
Generated
+3888
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "web-cad-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"postbuild": "cp src/database/schema.sql dist/database/schema.sql",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.0",
|
||||
"@fastify/static": "^7.0.0",
|
||||
"@fastify/websocket": "^10.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"fastify": "^4.28.0",
|
||||
"y-leveldb": "^0.2.0",
|
||||
"yjs": "^13.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"tsx": "^4.16.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* AuthService – Registration, Login, Session Management
|
||||
*/
|
||||
import bcrypt from 'bcrypt';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
userId: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
user: Omit<DBUser, 'password_hash'>;
|
||||
session: Session;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private sessions = new Map<string, Session>();
|
||||
|
||||
constructor(private db: DatabaseInterface) {}
|
||||
|
||||
async register(email: string, password: string, name: string, role: string = 'planer'): Promise<AuthResult> {
|
||||
const existing = this.db.getUserByEmail(email);
|
||||
if (existing) {
|
||||
throw new Error('Email already registered');
|
||||
}
|
||||
|
||||
const password_hash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
const user = this.db.createUser({ email, password_hash, name, role });
|
||||
const session = this.createSession(user.id);
|
||||
return { user: this.sanitizeUser(user), session };
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<AuthResult> {
|
||||
const user = this.db.getUserByEmail(email);
|
||||
if (!user) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const session = this.createSession(user.id);
|
||||
return { user: this.sanitizeUser(user), session };
|
||||
}
|
||||
|
||||
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
|
||||
const user = this.db.getUser(userId);
|
||||
if (!user) throw new Error('User not found');
|
||||
|
||||
const valid = await bcrypt.compare(oldPassword, user.password_hash);
|
||||
if (!valid) throw new Error('Invalid current password');
|
||||
|
||||
const password_hash = await bcrypt.hash(newPassword, SALT_ROUNDS);
|
||||
this.db.updateUser(userId, { password_hash });
|
||||
}
|
||||
|
||||
createSession(userId: string): Session {
|
||||
const token = randomUUID();
|
||||
const session: Session = {
|
||||
token,
|
||||
userId,
|
||||
expiresAt: Date.now() + SESSION_TTL_MS,
|
||||
};
|
||||
this.sessions.set(token, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
validateSession(token: string): Session | null {
|
||||
const session = this.sessions.get(token);
|
||||
if (!session) return null;
|
||||
if (Date.now() > session.expiresAt) {
|
||||
this.sessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
destroySession(token: string): void {
|
||||
this.sessions.delete(token);
|
||||
}
|
||||
|
||||
getUserFromSession(token: string): DBUser | null {
|
||||
const session = this.validateSession(token);
|
||||
if (!session) return null;
|
||||
return this.db.getUser(session.userId);
|
||||
}
|
||||
|
||||
private sanitizeUser(user: DBUser): Omit<DBUser, 'password_hash'> {
|
||||
const { password_hash, ...safe } = user;
|
||||
return safe;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Database Interface – abstract DB layer for Web CAD
|
||||
*/
|
||||
|
||||
export interface DBProject {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DBDrawing {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
data_json: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DBLayer {
|
||||
id: string;
|
||||
drawing_id: string;
|
||||
name: string;
|
||||
visible: number;
|
||||
locked: number;
|
||||
color: string;
|
||||
line_type: string;
|
||||
transparency: number;
|
||||
sort_order: number;
|
||||
parent_id: string | null;
|
||||
}
|
||||
|
||||
export interface DBElement {
|
||||
id: string;
|
||||
drawing_id: string;
|
||||
layer_id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
properties_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DBBlock {
|
||||
id: string;
|
||||
drawing_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
elements_json: string;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
export interface DBSetting {
|
||||
key: string;
|
||||
value: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DBUser {
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
name: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DatabaseInterface {
|
||||
init(): Promise<void>;
|
||||
close(): void;
|
||||
|
||||
// Users
|
||||
getUser(id: string): DBUser | null;
|
||||
getUserByEmail(email: string): DBUser | null;
|
||||
createUser(data: Partial<DBUser>): DBUser;
|
||||
updateUser(id: string, data: Partial<DBUser>): DBUser | null;
|
||||
deleteUser(id: string): boolean;
|
||||
listUsers(): DBUser[];
|
||||
|
||||
// Projects
|
||||
listProjects(): DBProject[];
|
||||
getProject(id: string): DBProject | null;
|
||||
createProject(data: Partial<DBProject>): DBProject;
|
||||
updateProject(id: string, data: Partial<DBProject>): DBProject | null;
|
||||
deleteProject(id: string): boolean;
|
||||
|
||||
// Drawings
|
||||
listDrawings(projectId: string): DBDrawing[];
|
||||
getDrawing(id: string): DBDrawing | null;
|
||||
createDrawing(data: Partial<DBDrawing>): DBDrawing;
|
||||
updateDrawing(id: string, data: Partial<DBDrawing>): DBDrawing | null;
|
||||
deleteDrawing(id: string): boolean;
|
||||
|
||||
// Layers
|
||||
listLayers(drawingId: string): DBLayer[];
|
||||
createLayer(data: Partial<DBLayer>): DBLayer;
|
||||
updateLayer(id: string, data: Partial<DBLayer>): DBLayer | null;
|
||||
deleteLayer(id: string): boolean;
|
||||
|
||||
// Elements
|
||||
listElements(drawingId: string): DBElement[];
|
||||
createElement(data: Partial<DBElement>): DBElement;
|
||||
updateElement(id: string, data: Partial<DBElement>): DBElement | null;
|
||||
deleteElement(id: string): boolean;
|
||||
|
||||
// Blocks
|
||||
listBlocks(drawingId: string): DBBlock[];
|
||||
createBlock(data: Partial<DBBlock>): DBBlock;
|
||||
updateBlock(id: string, data: Partial<DBBlock>): DBBlock | null;
|
||||
deleteBlock(id: string): boolean;
|
||||
|
||||
// Settings
|
||||
getSetting(key: string): DBSetting | null;
|
||||
setSetting(key: string, value: string): void;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* SQLite Adapter – implements DatabaseInterface with better-sqlite3
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type {
|
||||
DatabaseInterface, DBProject, DBDrawing, DBLayer,
|
||||
DBElement, DBBlock, DBSetting, DBUser,
|
||||
} from './DatabaseInterface.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export class SqliteAdapter implements DatabaseInterface {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(dbPath: string = ':memory:') {
|
||||
this.db = new Database(dbPath);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
const schemaPath = join(__dirname, 'schema.sql');
|
||||
const schema = readFileSync(schemaPath, 'utf-8');
|
||||
this.db.exec(schema);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
// ─── Users ──────────────────────────────────────────
|
||||
getUser(id: string): DBUser | null {
|
||||
return (this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DBUser) ?? null;
|
||||
}
|
||||
|
||||
getUserByEmail(email: string): DBUser | null {
|
||||
return (this.db.prepare('SELECT * FROM users WHERE email = ?').get(email) as DBUser) ?? null;
|
||||
}
|
||||
|
||||
createUser(data: Partial<DBUser>): DBUser {
|
||||
const id = data.id ?? `user-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(id, data.email!, data.password_hash!, data.name!, data.role ?? 'planer');
|
||||
return this.getUser(id)!;
|
||||
}
|
||||
|
||||
updateUser(id: string, data: Partial<DBUser>): DBUser | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
if (data.email !== undefined) { sets.push('email = ?'); vals.push(data.email); }
|
||||
if (data.password_hash !== undefined) { sets.push('password_hash = ?'); vals.push(data.password_hash); }
|
||||
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
|
||||
if (data.role !== undefined) { sets.push('role = ?'); vals.push(data.role); }
|
||||
sets.push("updated_at = datetime('now')");
|
||||
if (sets.length > 1) {
|
||||
this.db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.getUser(id);
|
||||
}
|
||||
|
||||
deleteUser(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
listUsers(): DBUser[] {
|
||||
return this.db.prepare('SELECT * FROM users ORDER BY created_at DESC').all() as DBUser[];
|
||||
}
|
||||
|
||||
// ─── Projects ──────────────────────────────────────
|
||||
listProjects(): DBProject[] {
|
||||
return this.db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as DBProject[];
|
||||
}
|
||||
|
||||
getProject(id: string): DBProject | null {
|
||||
return (this.db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as DBProject) ?? null;
|
||||
}
|
||||
|
||||
createProject(data: Partial<DBProject>): DBProject {
|
||||
const id = data.id ?? `proj-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO projects (id, name, description, owner_id) VALUES (?, ?, ?, ?)',
|
||||
).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default');
|
||||
return this.getProject(id)!;
|
||||
}
|
||||
|
||||
updateProject(id: string, data: Partial<DBProject>): DBProject | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
|
||||
if (data.description !== undefined) { sets.push('description = ?'); vals.push(data.description); }
|
||||
sets.push("updated_at = datetime('now')");
|
||||
if (sets.length > 1) {
|
||||
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.getProject(id);
|
||||
}
|
||||
|
||||
deleteProject(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM projects WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Drawings ──────────────────────────────────────
|
||||
listDrawings(projectId: string): DBDrawing[] {
|
||||
return this.db.prepare('SELECT * FROM drawings WHERE project_id = ? ORDER BY updated_at DESC').all(projectId) as DBDrawing[];
|
||||
}
|
||||
|
||||
getDrawing(id: string): DBDrawing | null {
|
||||
return (this.db.prepare('SELECT * FROM drawings WHERE id = ?').get(id) as DBDrawing) ?? null;
|
||||
}
|
||||
|
||||
createDrawing(data: Partial<DBDrawing>): DBDrawing {
|
||||
const id = data.id ?? `draw-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO drawings (id, project_id, name, data_json) VALUES (?, ?, ?, ?)',
|
||||
).run(id, data.project_id!, data.name ?? 'Unbenannt', data.data_json ?? '{}');
|
||||
return this.getDrawing(id)!;
|
||||
}
|
||||
|
||||
updateDrawing(id: string, data: Partial<DBDrawing>): DBDrawing | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
|
||||
if (data.data_json !== undefined) { sets.push('data_json = ?'); vals.push(data.data_json); }
|
||||
sets.push("updated_at = datetime('now')");
|
||||
if (sets.length > 1) {
|
||||
this.db.prepare(`UPDATE drawings SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.getDrawing(id);
|
||||
}
|
||||
|
||||
deleteDrawing(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM drawings WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Layers ────────────────────────────────────────
|
||||
listLayers(drawingId: string): DBLayer[] {
|
||||
return this.db.prepare('SELECT * FROM layers WHERE drawing_id = ? ORDER BY sort_order').all(drawingId) as DBLayer[];
|
||||
}
|
||||
|
||||
createLayer(data: Partial<DBLayer>): DBLayer {
|
||||
const id = data.id ?? `layer-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO layers (id, drawing_id, name, visible, locked, color, line_type, transparency, sort_order, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(id, data.drawing_id!, data.name ?? 'Layer', data.visible ?? 1, data.locked ?? 0,
|
||||
data.color ?? '#ffffff', data.line_type ?? 'solid', data.transparency ?? 0,
|
||||
data.sort_order ?? 0, data.parent_id ?? null);
|
||||
return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer;
|
||||
}
|
||||
|
||||
updateLayer(id: string, data: Partial<DBLayer>): DBLayer | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (['name','visible','locked','color','line_type','transparency','sort_order','parent_id'].includes(k)) {
|
||||
sets.push(`${k} = ?`); vals.push(v);
|
||||
}
|
||||
}
|
||||
if (sets.length > 0) {
|
||||
this.db.prepare(`UPDATE layers SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer ?? null;
|
||||
}
|
||||
|
||||
deleteLayer(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM layers WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Elements ──────────────────────────────────────
|
||||
listElements(drawingId: string): DBElement[] {
|
||||
return this.db.prepare('SELECT * FROM elements WHERE drawing_id = ?').all(drawingId) as DBElement[];
|
||||
}
|
||||
|
||||
createElement(data: Partial<DBElement>): DBElement {
|
||||
const id = data.id ?? `elem-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(id, data.drawing_id!, data.layer_id!, data.type!, data.x ?? 0, data.y ?? 0,
|
||||
data.width ?? 0, data.height ?? 0, data.properties_json ?? '{}');
|
||||
return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement;
|
||||
}
|
||||
|
||||
updateElement(id: string, data: Partial<DBElement>): DBElement | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (['layer_id','type','x','y','width','height','properties_json'].includes(k)) {
|
||||
sets.push(`${k} = ?`); vals.push(v);
|
||||
}
|
||||
}
|
||||
if (sets.length > 0) {
|
||||
this.db.prepare(`UPDATE elements SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement ?? null;
|
||||
}
|
||||
|
||||
deleteElement(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM elements WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Blocks ────────────────────────────────────────
|
||||
listBlocks(drawingId: string): DBBlock[] {
|
||||
return this.db.prepare('SELECT * FROM blocks WHERE drawing_id = ?').all(drawingId) as DBBlock[];
|
||||
}
|
||||
|
||||
createBlock(data: Partial<DBBlock>): DBBlock {
|
||||
const id = data.id ?? `block-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO blocks (id, drawing_id, name, description, category, elements_json, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(id, data.drawing_id!, data.name ?? 'Block', data.description ?? null,
|
||||
data.category ?? 'Allgemein', data.elements_json ?? '[]', data.thumbnail ?? null);
|
||||
return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock;
|
||||
}
|
||||
|
||||
updateBlock(id: string, data: Partial<DBBlock>): DBBlock | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (['name','description','category','elements_json','thumbnail'].includes(k)) {
|
||||
sets.push(`${k} = ?`); vals.push(v);
|
||||
}
|
||||
}
|
||||
if (sets.length > 0) {
|
||||
this.db.prepare(`UPDATE blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock ?? null;
|
||||
}
|
||||
|
||||
deleteBlock(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM blocks WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Settings ──────────────────────────────────────
|
||||
getSetting(key: string): DBSetting | null {
|
||||
return (this.db.prepare('SELECT * FROM settings WHERE key = ?').get(key) as DBSetting) ?? null;
|
||||
}
|
||||
|
||||
setSetting(key: string, value: string): void {
|
||||
this.db.prepare(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')",
|
||||
).run(key, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Web CAD Backend – SQLite Schema
|
||||
-- Version 1.0
|
||||
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ─── Users ──────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'planer' CHECK(role IN ('admin','planer','betrachter','gast')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ─── Default User (seed) ────────────────────────────
|
||||
INSERT OR IGNORE INTO users (id, email, password_hash, name, role) VALUES ('user-default', 'default@webcad.local', '', 'Default User', 'admin');
|
||||
|
||||
-- ─── Projects ───────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Drawings ───────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS drawings (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Layers ─────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS layers (
|
||||
id TEXT PRIMARY KEY,
|
||||
drawing_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
visible INTEGER NOT NULL DEFAULT 1,
|
||||
locked INTEGER NOT NULL DEFAULT 0,
|
||||
color TEXT NOT NULL DEFAULT '#ffffff',
|
||||
line_type TEXT NOT NULL DEFAULT 'solid',
|
||||
transparency REAL NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
parent_id TEXT,
|
||||
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Elements ───────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS elements (
|
||||
id TEXT PRIMARY KEY,
|
||||
drawing_id TEXT NOT NULL,
|
||||
layer_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
width REAL NOT NULL,
|
||||
height REAL NOT NULL,
|
||||
properties_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (layer_id) REFERENCES layers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Block Definitions ──────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id TEXT PRIMARY KEY,
|
||||
drawing_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'Allgemein',
|
||||
elements_json TEXT NOT NULL DEFAULT '[]',
|
||||
thumbnail TEXT,
|
||||
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Settings ───────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ─── Indexes ────────────────────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_drawings_project ON drawings(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_layers_drawing ON layers(drawing_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_elements_drawing ON elements(drawing_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_elements_layer ON elements(layer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_drawing ON blocks(drawing_id);
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Web CAD Backend – Entry Point
|
||||
*/
|
||||
import { createServer } from './server.js';
|
||||
import { SqliteAdapter } from './database/SqliteAdapter.js';
|
||||
import { initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const DB_PATH = process.env.DB_PATH || '/data/web-cad.db';
|
||||
|
||||
async function main() {
|
||||
const db = new SqliteAdapter(DB_PATH);
|
||||
await db.init();
|
||||
await initYjsPersistence();
|
||||
|
||||
const fastify = await createServer({ port: PORT, host: HOST, db });
|
||||
|
||||
try {
|
||||
await fastify.listen({ port: PORT, host: HOST });
|
||||
console.log(`Web CAD Backend running on http://${HOST}:${PORT}`);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
await fastify.close();
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGINT', async () => {
|
||||
await fastify.close();
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* AI Routes – KI Copilot chat endpoint via OpenRouter
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
const MODEL = 'anthropic/claude-sonnet-4.5';
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
function extractToken(request: any): string | null {
|
||||
const auth = request.headers?.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerAIRoutes(fastify: FastifyInstance, authService: AuthService) {
|
||||
fastify.post('/api/ai/chat', async (request, reply) => {
|
||||
// Auth check
|
||||
const token = extractToken(request);
|
||||
if (!token) {
|
||||
return reply.code(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
}
|
||||
|
||||
const { messages, context } = request.body as {
|
||||
messages?: Array<{ role: string; content: string }>;
|
||||
context?: {
|
||||
projectName?: string;
|
||||
elementCount?: number;
|
||||
layerCount?: number;
|
||||
elementTypeSummary?: Record<string, number>;
|
||||
};
|
||||
};
|
||||
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return reply.code(400).send({ error: 'Messages array is required' });
|
||||
}
|
||||
|
||||
const apiKey = process.env.API_KEY_OPENROUTER;
|
||||
if (!apiKey) {
|
||||
return reply.code(503).send({ error: 'AI service not configured' });
|
||||
}
|
||||
|
||||
// Build system prompt with CAD context
|
||||
const ctxParts: string[] = [
|
||||
'Du bist der KI Copilot für Web CAD, eine webbasierte CAD-Anwendung für Event- und Raumplanung.',
|
||||
'Du hilfst beim Anlegen von Bestuhlung, Räumen, Bühnen und anderen CAD-Elementen.',
|
||||
'Antworte auf Deutsch, klar und präzise. Verwende Markdown für Formatierung wenn sinnvoll.',
|
||||
];
|
||||
|
||||
if (context) {
|
||||
if (context.projectName) ctxParts.push(`Aktuelles Projekt: ${context.projectName}`);
|
||||
if (context.elementCount !== undefined) ctxParts.push(`Elemente im Projekt: ${context.elementCount}`);
|
||||
if (context.layerCount !== undefined) ctxParts.push(`Ebenen: ${context.layerCount}`);
|
||||
if (context.elementTypeSummary) {
|
||||
const summary = Object.entries(context.elementTypeSummary)
|
||||
.map(([type, count]) => `${type}: ${count}`)
|
||||
.join(', ');
|
||||
ctxParts.push(`Element-Typen: ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = ctxParts.join('\n');
|
||||
|
||||
// Build OpenRouter request
|
||||
const payload = {
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
],
|
||||
max_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(OPENROUTER_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'HTTP-Referer': 'http://localhost:5173',
|
||||
'X-Title': 'Web CAD KI Copilot',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
request.log.error({ errText, status: response.status }, 'OpenRouter error');
|
||||
return reply.code(502).send({ error: 'AI service returned an error' });
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
const content = data.choices?.[0]?.message?.content ?? '';
|
||||
|
||||
if (!content) {
|
||||
return reply.code(502).send({ error: 'AI service returned empty response' });
|
||||
}
|
||||
|
||||
// Extract suggestions from response if present (simple heuristic)
|
||||
const suggestions: string[] = [];
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^[-*]\s*(.+)/);
|
||||
if (match && suggestions.length < 3) {
|
||||
suggestions.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ content, suggestions: suggestions.length > 0 ? suggestions : undefined });
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
return reply.code(504).send({ error: 'AI service timeout' });
|
||||
}
|
||||
request.log.error({ err }, 'AI route error');
|
||||
return reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Auth Routes – Registration, Login, Logout, Profile
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
|
||||
export function registerAuthRoutes(fastify: FastifyInstance, authService: AuthService) {
|
||||
// Register new user
|
||||
fastify.post('/api/auth/register', async (request, reply) => {
|
||||
const { email, password, name, role } = request.body as {
|
||||
email?: string; password?: string; name?: string; role?: string;
|
||||
};
|
||||
if (!email || !password || !name) {
|
||||
return reply.code(400).send({ error: 'Email, password and name are required' });
|
||||
}
|
||||
try {
|
||||
const result = await authService.register(email, password, name, role);
|
||||
return reply.code(201).send(result);
|
||||
} catch (err: any) { return reply.code(409).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
fastify.post('/api/auth/login', async (request, reply) => {
|
||||
const { email, password } = request.body as { email?: string; password?: string };
|
||||
if (!email || !password) {
|
||||
return reply.code(400).send({ error: 'Email and password are required' });
|
||||
}
|
||||
try {
|
||||
const result = await authService.login(email, password);
|
||||
return reply.send(result);
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
fastify.post('/api/auth/logout', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (token) {
|
||||
authService.destroySession(token);
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
fastify.get('/api/auth/me', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
const { password_hash, ...safe } = user;
|
||||
return safe;
|
||||
});
|
||||
|
||||
// Change password
|
||||
fastify.patch('/api/auth/password', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
|
||||
const { oldPassword, newPassword } = request.body as { oldPassword?: string; newPassword?: string };
|
||||
if (!oldPassword || !newPassword) {
|
||||
return reply.code(400).send({ error: 'oldPassword and newPassword are required' });
|
||||
}
|
||||
try {
|
||||
await authService.changePassword(user.id, oldPassword, newPassword);
|
||||
return reply.code(204).send();
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function extractToken(request: any): string | null {
|
||||
const auth = request.headers?.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Blocks Routes – CRUD for reusable drawing blocks
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBBlock } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List all blocks for a drawing
|
||||
fastify.get('/api/drawings/:drawingId/blocks', async (request) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
return db.listBlocks(drawingId);
|
||||
});
|
||||
|
||||
// Create a new block
|
||||
fastify.post('/api/drawings/:drawingId/blocks', async (request, reply) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
const body = request.body as Partial<DBBlock>;
|
||||
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
|
||||
return reply.code(201).send(db.createBlock({ ...body, drawing_id: drawingId }));
|
||||
});
|
||||
|
||||
// Update a block
|
||||
fastify.patch('/api/blocks/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBBlock>;
|
||||
const updated = db.updateBlock(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Block not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete a block
|
||||
fastify.delete('/api/blocks/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteBlock(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Block not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Drawings Routes – CRUD for drawings
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBDrawing } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List drawings for a project
|
||||
fastify.get('/api/projects/:projectId/drawings', async (request) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
return db.listDrawings(projectId);
|
||||
});
|
||||
|
||||
// Get single drawing
|
||||
fastify.get('/api/drawings/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const drawing = db.getDrawing(id);
|
||||
if (!drawing) return reply.code(404).send({ error: 'Drawing not found' });
|
||||
return drawing;
|
||||
});
|
||||
|
||||
// Create drawing
|
||||
fastify.post('/api/projects/:projectId/drawings', async (request, reply) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
const body = request.body as Partial<DBDrawing>;
|
||||
return reply.code(201).send(db.createDrawing({ ...body, project_id: projectId }));
|
||||
});
|
||||
|
||||
// Update drawing
|
||||
fastify.patch('/api/drawings/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBDrawing>;
|
||||
const updated = db.updateDrawing(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Drawing not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete drawing
|
||||
fastify.delete('/api/drawings/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteDrawing(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Drawing not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Elements Routes – CRUD for elements
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBElement } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List elements for a drawing
|
||||
fastify.get('/api/drawings/:drawingId/elements', async (request) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
return db.listElements(drawingId);
|
||||
});
|
||||
|
||||
// Create element
|
||||
fastify.post('/api/drawings/:drawingId/elements', async (request, reply) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
const body = request.body as Partial<DBElement>;
|
||||
return reply.code(201).send(db.createElement({ ...body, drawing_id: drawingId }));
|
||||
});
|
||||
|
||||
// Update element
|
||||
fastify.patch('/api/elements/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBElement>;
|
||||
const updated = db.updateElement(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Element not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete element
|
||||
fastify.delete('/api/elements/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteElement(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Element not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Layers Routes – CRUD for layers
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBLayer } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List layers for a drawing
|
||||
fastify.get('/api/drawings/:drawingId/layers', async (request) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
return db.listLayers(drawingId);
|
||||
});
|
||||
|
||||
// Create layer
|
||||
fastify.post('/api/drawings/:drawingId/layers', async (request, reply) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
const body = request.body as Partial<DBLayer>;
|
||||
return reply.code(201).send(db.createLayer({ ...body, drawing_id: drawingId }));
|
||||
});
|
||||
|
||||
// Update layer
|
||||
fastify.patch('/api/layers/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBLayer>;
|
||||
const updated = db.updateLayer(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Layer not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete layer
|
||||
fastify.delete('/api/layers/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteLayer(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Layer not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Projects Routes – CRUD for projects
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBProject } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List all projects
|
||||
fastify.get('/api/projects', async () => {
|
||||
return db.listProjects();
|
||||
});
|
||||
|
||||
// Get single project
|
||||
fastify.get('/api/projects/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const project = db.getProject(id);
|
||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
||||
return project;
|
||||
});
|
||||
|
||||
// Create project
|
||||
fastify.post('/api/projects', async (request, reply) => {
|
||||
const body = request.body as Partial<DBProject>;
|
||||
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
|
||||
return reply.code(201).send(db.createProject(body));
|
||||
});
|
||||
|
||||
// Update project
|
||||
fastify.patch('/api/projects/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBProject>;
|
||||
const updated = db.updateProject(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Project not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete project
|
||||
fastify.delete('/api/projects/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteProject(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Project not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Settings Routes – key/value application settings
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// Get a single setting by key
|
||||
fastify.get('/api/settings/:key', async (request, reply) => {
|
||||
const { key } = request.params as { key: string };
|
||||
const setting = db.getSetting(key);
|
||||
if (!setting) return reply.code(404).send({ error: 'Setting not found' });
|
||||
return setting;
|
||||
});
|
||||
|
||||
// Create or update a setting (upsert)
|
||||
fastify.put('/api/settings/:key', async (request) => {
|
||||
const { key } = request.params as { key: string };
|
||||
const body = request.body as { value?: string };
|
||||
if (body.value === undefined) {
|
||||
return { error: 'Value is required' };
|
||||
}
|
||||
db.setSetting(key, body.value);
|
||||
return { key, value: body.value, updated_at: new Date().toISOString() };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Users Routes – Admin user management
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
|
||||
export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||
// List all users (admin only — TODO: add role check middleware)
|
||||
fastify.get('/api/users', async () => {
|
||||
const users = db.listUsers();
|
||||
return users.map(({ password_hash, ...safe }) => safe);
|
||||
});
|
||||
|
||||
// Get single user
|
||||
fastify.get('/api/users/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const user = db.getUser(id);
|
||||
if (!user) return reply.code(404).send({ error: 'User not found' });
|
||||
const { password_hash, ...safe } = user;
|
||||
return safe;
|
||||
});
|
||||
|
||||
// Update user (name, role, email)
|
||||
fastify.patch('/api/users/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBUser>;
|
||||
// Prevent password update via this endpoint
|
||||
delete body.password_hash;
|
||||
const updated = db.updateUser(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'User not found' });
|
||||
const { password_hash, ...safe } = updated;
|
||||
return safe;
|
||||
});
|
||||
|
||||
// Delete user
|
||||
fastify.delete('/api/users/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteUser(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'User not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Server – Fastify configuration with CORS, static, and WebSocket
|
||||
*/
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import staticPlugin from '@fastify/static';
|
||||
import websocket from '@fastify/websocket';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { DatabaseInterface } from './database/DatabaseInterface.js';
|
||||
import { AuthService } from './auth/AuthService.js';
|
||||
import { registerYjsWebSocket, initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface ServerOptions {
|
||||
port?: number;
|
||||
host?: string;
|
||||
db: DatabaseInterface;
|
||||
}
|
||||
|
||||
export async function createServer(opts: ServerOptions) {
|
||||
const fastify = Fastify({ logger: true });
|
||||
|
||||
// CORS
|
||||
await fastify.register(cors, {
|
||||
origin: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
});
|
||||
|
||||
// WebSocket
|
||||
await fastify.register(websocket, {
|
||||
options: { maxPayload: 1048576 },
|
||||
});
|
||||
|
||||
// Static files (serve frontend build if exists)
|
||||
const frontendDist = join(__dirname, '..', '..', 'frontend', 'dist');
|
||||
try {
|
||||
await fastify.register(staticPlugin, {
|
||||
root: frontendDist,
|
||||
prefix: '/',
|
||||
});
|
||||
} catch {
|
||||
// Frontend dist may not exist in dev mode
|
||||
}
|
||||
|
||||
// Health check
|
||||
fastify.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||
|
||||
// Register route modules
|
||||
const { registerProjectRoutes } = await import('./routes/projects.js');
|
||||
const { registerDrawingRoutes } = await import('./routes/drawings.js');
|
||||
const { registerLayerRoutes } = await import('./routes/layers.js');
|
||||
const { registerElementRoutes } = await import('./routes/elements.js');
|
||||
const { registerBlockRoutes } = await import('./routes/blocks.js');
|
||||
const { registerSettingsRoutes } = await import('./routes/settings.js');
|
||||
const { registerAuthRoutes } = await import('./routes/auth.js');
|
||||
const { registerUserRoutes } = await import('./routes/users.js');
|
||||
const { registerAIRoutes } = await import('./routes/ai.js');
|
||||
|
||||
const authService = new AuthService(opts.db);
|
||||
|
||||
registerAuthRoutes(fastify, authService);
|
||||
registerProjectRoutes(fastify, opts.db);
|
||||
registerDrawingRoutes(fastify, opts.db);
|
||||
registerLayerRoutes(fastify, opts.db);
|
||||
registerElementRoutes(fastify, opts.db);
|
||||
registerBlockRoutes(fastify, opts.db);
|
||||
registerSettingsRoutes(fastify, opts.db);
|
||||
registerUserRoutes(fastify, opts.db, authService);
|
||||
registerAIRoutes(fastify, authService);
|
||||
|
||||
// Yjs collaboration WebSocket
|
||||
registerYjsWebSocket(fastify);
|
||||
|
||||
return fastify;
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
declare module 'y-leveldb' {
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
export class LeveldbPersistence {
|
||||
constructor(dbPath: string, opts?: Record<string, unknown>);
|
||||
getYDoc(name: string): Promise<Y.Doc>;
|
||||
storeUpdate(name: string, update: Uint8Array): Promise<number>;
|
||||
destroy(): Promise<void>;
|
||||
clearDocument(name: string): Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Yjs WebSocket Server – Real-time collaboration with LevelDB persistence
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import { LeveldbPersistence } from 'y-leveldb';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
|
||||
const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents';
|
||||
|
||||
// Document store: docName → Y.Doc
|
||||
const docs = new Map<string, Y.Doc>();
|
||||
// Connection tracking: docName → Set<WebSocket>
|
||||
const connections = new Map<string, Set<WebSocket>>();
|
||||
let persistence: LeveldbPersistence | null = null;
|
||||
let persistenceReady: Promise<void> | null = null;
|
||||
|
||||
export async function initYjsPersistence(): Promise<void> {
|
||||
try {
|
||||
persistence = new LeveldbPersistence(PERSISTENCE_DIR);
|
||||
console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`);
|
||||
} catch (err) {
|
||||
console.warn('Yjs persistence failed to init (non-fatal):', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeYjsPersistence(): Promise<void> {
|
||||
if (persistence) {
|
||||
await persistence.destroy();
|
||||
persistence = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getOrCreateDoc(docName: string): Promise<Y.Doc> {
|
||||
if (docs.has(docName)) {
|
||||
return docs.get(docName)!;
|
||||
}
|
||||
|
||||
const doc = new Y.Doc();
|
||||
docs.set(docName, doc);
|
||||
|
||||
// Load persisted state if available
|
||||
if (persistence && persistenceReady) {
|
||||
try {
|
||||
await persistenceReady;
|
||||
const persistedYdoc = await persistence.getYDoc(docName);
|
||||
const update = Y.encodeStateAsUpdate(persistedYdoc);
|
||||
if (update.length > 0) {
|
||||
Y.applyUpdate(doc, update);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to load doc ${docName}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist updates to LevelDB
|
||||
doc.on('update', (update: Uint8Array) => {
|
||||
if (persistence) {
|
||||
persistence.storeUpdate(docName, update).catch((err: any) => {
|
||||
console.warn(`Failed to persist update for ${docName}:`, err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
function broadcastUpdate(docName: string, update: Uint8Array, exclude?: WebSocket): void {
|
||||
const conns = connections.get(docName);
|
||||
if (!conns) return;
|
||||
|
||||
for (const ws of conns) {
|
||||
if (ws !== exclude && ws.readyState === 1 /* OPEN */) {
|
||||
try {
|
||||
ws.send(update);
|
||||
} catch {
|
||||
// Connection error — will be cleaned up on close
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerYjsWebSocket(fastify: FastifyInstance): void {
|
||||
fastify.get('/ws/collab/:docName', { websocket: true }, async (socket: WebSocket, request: any) => {
|
||||
const docName = request.params.docName as string;
|
||||
if (!docName) {
|
||||
socket.close(4000, 'Missing docName');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await getOrCreateDoc(docName);
|
||||
|
||||
// Track connection
|
||||
if (!connections.has(docName)) {
|
||||
connections.set(docName, new Set());
|
||||
}
|
||||
connections.get(docName)!.add(socket);
|
||||
|
||||
// Send current document state to new client
|
||||
const stateUpdate = Y.encodeStateAsUpdate(doc);
|
||||
if (stateUpdate.length > 0 && socket.readyState === 1) {
|
||||
socket.send(stateUpdate);
|
||||
}
|
||||
|
||||
// Listen for updates from this client
|
||||
socket.on('message', (data: Buffer) => {
|
||||
try {
|
||||
const update = new Uint8Array(data);
|
||||
Y.applyUpdate(doc, update);
|
||||
broadcastUpdate(docName, update, socket);
|
||||
} catch (err) {
|
||||
console.warn(`Invalid update from client for ${docName}:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up on disconnect
|
||||
socket.on('close', () => {
|
||||
const conns = connections.get(docName);
|
||||
if (conns) {
|
||||
conns.delete(socket);
|
||||
if (conns.size === 0) {
|
||||
connections.delete(docName);
|
||||
// Optionally keep doc in memory for a while
|
||||
// For now, remove it to free memory
|
||||
docs.delete(docName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
const conns = connections.get(docName);
|
||||
if (conns) {
|
||||
conns.delete(socket);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- backend-data:/app/data
|
||||
environment:
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- API_KEY_OPENROUTER=${API_KEY_OPENROUTER:-}
|
||||
- DB_PATH=/app/data/cad.db
|
||||
- PORT=3001
|
||||
- NODE_ENV=production
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
backend-data:
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
*.log
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Web CAD</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
location /api/ { proxy_pass http://backend:3001; }
|
||||
location /ws { proxy_pass http://backend:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }
|
||||
}
|
||||
Generated
+2565
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "web-cad-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"dxf-parser": "^1.1.2",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"rbush": "^4.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"y-websocket": "^2.0.0",
|
||||
"yjs": "^13.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/rbush": "^4.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,942 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import type {
|
||||
Theme, RibbonTab, ViewMode, RightPanel, DrawerTab,
|
||||
CursorPos, CommandHistoryEntry, KIMessage, KISuggestion,
|
||||
} from './types/ui.types';
|
||||
import type { CADElement, CADLayer, BlockDefinition } from './types/cad.types';
|
||||
import { createDefaultBlocks, BlockService } from './services/blockService';
|
||||
import { SeatingService } from './services/seatingService';
|
||||
import type { ToolState } from './interaction';
|
||||
import { GroupManager, type ElementGroup } from './tools/modification/GroupTool';
|
||||
import Topbar from './components/Topbar';
|
||||
import RibbonBar from './components/RibbonBar';
|
||||
import LeftSidebar from './components/LeftSidebar';
|
||||
import CanvasArea from './components/CanvasArea';
|
||||
import RightSidebar from './components/RightSidebar';
|
||||
import CommandLine from './components/CommandLine';
|
||||
import StatusBar from './components/StatusBar';
|
||||
import MobileDrawers from './components/MobileDrawers';
|
||||
import BackgroundImport from './components/BackgroundImport';
|
||||
import HistoryPanel from './components/HistoryPanel';
|
||||
import { BackgroundService, type BackgroundConfig } from './services/backgroundService';
|
||||
import { HistoryManager, type CADStateSnapshot, type HistoryEntry } from './history';
|
||||
import { getCommandRegistry } from './services/commandRegistry';
|
||||
import { importFile, type ImportResult } from './services/importService';
|
||||
import { exportProject, downloadBlob, type ExportFormat } from './services/exportService';
|
||||
import type { ProjectData } from './types/cad.types';
|
||||
import { loadProjectDataTyped, createElementTyped, updateElement, deleteElement as apiDeleteElement, createLayerTyped, updateLayer, deleteLayer as apiDeleteLayer, createBlockTyped, updateBlock, deleteBlock as apiDeleteBlock, aiChat } from './services/api';
|
||||
import { useYjsBinding } from './crdt';
|
||||
import { registerBuiltinPlugins, pluginRegistry } from './plugins';
|
||||
import type { PluginContext } from './plugins';
|
||||
import './styles.css';
|
||||
import './styles/auth.css';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import { Login } from './pages/Login';
|
||||
import { Register } from './pages/Register';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
|
||||
// ─── Mock Data ──────────────────────────────────────────────
|
||||
const initialLayers: CADLayer[] = [
|
||||
{ id: 'layer-0', name: 'Wände', visible: true, locked: false, color: '#e74c3c', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null },
|
||||
{ id: 'layer-1', name: 'Türen', visible: true, locked: false, color: '#3498db', lineType: 'solid', transparency: 0, sortOrder: 1, parentId: null },
|
||||
{ id: 'layer-2', name: 'Bestuhlung', visible: true, locked: false, color: '#2ecc71', lineType: 'solid', transparency: 0, sortOrder: 2, parentId: null },
|
||||
{ id: 'layer-3', name: 'Bühne', visible: true, locked: false, color: '#f39c12', lineType: 'solid', transparency: 0, sortOrder: 3, parentId: null },
|
||||
{ id: 'layer-4', name: 'Hintergrund', visible: true, locked: true, color: '#95a5a6', lineType: 'dotted', transparency: 50, sortOrder: 4, parentId: null },
|
||||
];
|
||||
|
||||
const initialBlocks: BlockDefinition[] = createDefaultBlocks();
|
||||
|
||||
const initialCommandHistory: CommandHistoryEntry[] = [
|
||||
{ prefix: '·', text: 'Bereit · Werkzeug: Auswahl · 110 Objekte · 5 Ebenen', type: 'info' },
|
||||
{ prefix: '·', text: 'Auto-Save aktiv · letzte Speicherung vor 3 Sekunden', type: 'info' },
|
||||
];
|
||||
|
||||
const initialKIMessages: KIMessage[] = [
|
||||
{ id: 'ki-1', role: 'assistant', content: 'Hallo! Ich bin der KI Copilot. Wie kann ich helfen?' },
|
||||
];
|
||||
|
||||
const initialKISuggestions: KISuggestion[] = [
|
||||
{ id: 'sug-1', label: 'Bestuhlung automatisch generieren' },
|
||||
{ id: 'sug-2', label: 'Maße analysieren' },
|
||||
{ id: 'sug-3', label: 'Flächen berechnen' },
|
||||
];
|
||||
|
||||
// Prevent duplicate drawing creation across StrictMode double-render
|
||||
const loadedProjects = new Set<string>();
|
||||
|
||||
// ─── CAD Editor Component ───────────────────────────────
|
||||
interface CADEditorProps {
|
||||
projectId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const CADEditor: React.FC<CADEditorProps> = ({ projectId, token }) => {
|
||||
// Theme
|
||||
const [theme, setTheme] = useState<Theme>('dark');
|
||||
|
||||
// Auth user for collaboration
|
||||
const { user } = useAuth();
|
||||
|
||||
// Yjs collaboration binding
|
||||
const collab = useYjsBinding({
|
||||
docName: `project-${projectId}`,
|
||||
userId: user?.id || 'anonymous',
|
||||
userName: user?.name || 'Gast',
|
||||
userColor: '#3498db',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// Project
|
||||
const [projectName, setProjectName] = useState('Unbenannt');
|
||||
const [savedStatus, setSavedStatus] = useState('Lädt…');
|
||||
const [drawingId, setDrawingId] = useState<string | null>(null);
|
||||
|
||||
// Ribbon
|
||||
const [activeRibbonTab, setActiveRibbonTab] = useState<RibbonTab>('start');
|
||||
|
||||
// Tools
|
||||
const [activeTool, setActiveTool] = useState('select');
|
||||
|
||||
// Canvas / View
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('2d');
|
||||
const [cursorPos, setCursorPos] = useState<CursorPos>({ x: 0, y: 0 });
|
||||
const [gridEnabled, setGridEnabled] = useState(true);
|
||||
const [orthoEnabled, setOrthoEnabled] = useState(false);
|
||||
const [snapEnabled, setSnapEnabled] = useState(true);
|
||||
const [polarEnabled, setPolarEnabled] = useState(false);
|
||||
|
||||
// Right sidebar
|
||||
const [activeRightPanel, setActiveRightPanel] = useState<RightPanel>('tool');
|
||||
const [selectedElement, setSelectedElement] = useState<CADElement | null>(null);
|
||||
const [layers, setLayers] = useState<CADLayer[]>(initialLayers);
|
||||
const [activeLayerId, setActiveLayerId] = useState<string>('layer-0');
|
||||
const [blocks, setBlocks] = useState<BlockDefinition[]>(initialBlocks);
|
||||
const [blockCategory, setBlockCategory] = useState('Alle');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
|
||||
// Command line
|
||||
const [commandHistory, setCommandHistory] = useState<CommandHistoryEntry[]>(initialCommandHistory);
|
||||
|
||||
// KI Copilot
|
||||
const [kiMessages, setKIMessages] = useState<KIMessage[]>(initialKIMessages);
|
||||
const [kiSuggestions] = useState<KISuggestion[]>(initialKISuggestions);
|
||||
const [kiLoading, setKiLoading] = useState(false);
|
||||
|
||||
// Plugin system
|
||||
useEffect(() => {
|
||||
const ctx: PluginContext = {
|
||||
addElement: (el) => setElements((prev) => [...prev, el]),
|
||||
removeElement: (id) => setElements((prev) => prev.filter((e) => e.id !== id)),
|
||||
updateElement: (id, props) => setElements((prev) => prev.map((e) => (e.id === id ? { ...e, ...props } : e))),
|
||||
getElements: () => elements,
|
||||
getLayers: () => layers,
|
||||
getActiveLayerId: () => activeLayerId,
|
||||
showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]),
|
||||
log: (msg) => console.log(`[Plugin] ${msg}`),
|
||||
};
|
||||
pluginRegistry.setContext(ctx);
|
||||
registerBuiltinPlugins();
|
||||
pluginRegistry.initDefaults();
|
||||
}, []);
|
||||
|
||||
// Status bar
|
||||
const onlineCount = collab.cursors.length + 1;
|
||||
|
||||
// Mobile drawers
|
||||
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
|
||||
const [mobileRightOpen, setMobileRightOpen] = useState(false);
|
||||
const [activeDrawerTab, setActiveDrawerTab] = useState<DrawerTab>('tool');
|
||||
|
||||
// Background
|
||||
const [bgImportOpen, setBgImportOpen] = useState(false);
|
||||
const [bgConfig, setBgConfig] = useState<BackgroundConfig | null>(null);
|
||||
const bgServiceRef = React.useRef<BackgroundService>(new BackgroundService());
|
||||
|
||||
// History panel
|
||||
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
||||
|
||||
// Elements + undo/redo (HistoryManager)
|
||||
const [elements, setElements] = useState<CADElement[]>([]);
|
||||
const historyManagerRef = React.useRef<HistoryManager>(new HistoryManager());
|
||||
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
||||
const [toolState, setToolState] = useState<ToolState | null>(null);
|
||||
|
||||
const seatCount = useMemo(() => {
|
||||
const svc = new SeatingService();
|
||||
return svc.countSeats(elements).total;
|
||||
}, [elements]);
|
||||
|
||||
// Groups
|
||||
const groupManagerRef = React.useRef(new GroupManager());
|
||||
const [groups, setGroups] = useState<ElementGroup[]>([]);
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────
|
||||
const handleThemeToggle = useCallback(() => {
|
||||
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
}, []);
|
||||
|
||||
const syncHistory = useCallback(() => {
|
||||
setHistoryEntries(historyManagerRef.current.getHistory());
|
||||
}, []);
|
||||
|
||||
const restoreSnapshot = useCallback((snap: CADStateSnapshot) => {
|
||||
setElements(snap.elements);
|
||||
setLayers(snap.layers);
|
||||
setBlocks(snap.blocks);
|
||||
setGroups(snap.groups);
|
||||
setBgConfig(snap.bgConfig);
|
||||
}, []);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
const snap = historyManagerRef.current.undo();
|
||||
if (snap) {
|
||||
restoreSnapshot(snap);
|
||||
syncHistory();
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rückgängig: letzte Aktion', type: 'info' }]);
|
||||
}
|
||||
}, [restoreSnapshot, syncHistory]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
const snap = historyManagerRef.current.redo();
|
||||
if (snap) {
|
||||
restoreSnapshot(snap);
|
||||
syncHistory();
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Wiederherstellen: letzte Aktion', type: 'info' }]);
|
||||
}
|
||||
}, [restoreSnapshot, syncHistory]);
|
||||
|
||||
const pushHistorySnapshot = useCallback((label: string) => {
|
||||
historyManagerRef.current.pushSnapshot({
|
||||
elements,
|
||||
layers,
|
||||
blocks,
|
||||
groups,
|
||||
bgConfig,
|
||||
}, label);
|
||||
syncHistory();
|
||||
}, [elements, layers, blocks, groups, bgConfig, syncHistory]);
|
||||
|
||||
// Initialize HistoryManager with initial state on mount
|
||||
const historyInitRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (historyInitRef.current) return;
|
||||
historyInitRef.current = true;
|
||||
historyManagerRef.current.initialize({
|
||||
elements: [],
|
||||
layers: initialLayers,
|
||||
blocks: initialBlocks,
|
||||
groups: [],
|
||||
bgConfig: null,
|
||||
});
|
||||
syncHistory();
|
||||
}, [syncHistory]);
|
||||
|
||||
// Load project data from backend on mount
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!projectId || !token) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setSavedStatus('Lädt…');
|
||||
const data = await loadProjectDataTyped(token, projectId);
|
||||
if (cancelled) return;
|
||||
setProjectName(data.project.name);
|
||||
setDrawingId(data.drawing?.id || null);
|
||||
if (data.elements.length > 0) setElements(data.elements);
|
||||
if (data.layers.length > 0) setLayers(data.layers);
|
||||
if (data.blocks.length > 0) setBlocks(data.blocks);
|
||||
// Save initial layers to backend if backend has none
|
||||
if (data.layers.length === 0 && data.drawing) {
|
||||
for (const layer of initialLayers) {
|
||||
createLayerTyped(token, data.drawing.id, layer).catch(() => {});
|
||||
}
|
||||
}
|
||||
setSavedStatus('gespeichert');
|
||||
setDataLoaded(true);
|
||||
// Push initial data to Yjs CRDT after load
|
||||
if (collab.status === 'connected') {
|
||||
collab.loadFromState({ elements: data.elements, layers: data.layers, blocks: data.blocks });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err);
|
||||
setSavedStatus('Fehler beim Laden');
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId, token]);
|
||||
|
||||
// Sync remote → local: when Yjs data changes from other users, update local state
|
||||
React.useEffect(() => {
|
||||
if (collab.status !== 'connected') return;
|
||||
// Skip if collab data is empty (not yet loaded)
|
||||
if (collab.elements.length === 0 && collab.layers.length === 0 && collab.blocks.length === 0) return;
|
||||
|
||||
// Only update local state if remote data differs
|
||||
const sameElements = collab.elements.length === elements.length &&
|
||||
collab.elements.every((el, i) => el.id === elements[i]?.id);
|
||||
if (!sameElements) setElements(collab.elements);
|
||||
|
||||
const sameLayers = collab.layers.length === layers.length &&
|
||||
collab.layers.every((l, i) => l.id === layers[i]?.id);
|
||||
if (!sameLayers) setLayers(collab.layers);
|
||||
|
||||
const sameBlocks = collab.blocks.length === blocks.length &&
|
||||
collab.blocks.every((b, i) => b.id === blocks[i]?.id);
|
||||
if (!sameBlocks) setBlocks(collab.blocks);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [collab.elements, collab.layers, collab.blocks, collab.status]);
|
||||
|
||||
const handleElementCreated = useCallback((el: CADElement) => {
|
||||
const newElements = [...elements, el];
|
||||
historyManagerRef.current.pushSnapshot({
|
||||
elements: newElements, layers, blocks, groups, bgConfig,
|
||||
}, 'Element erstellt');
|
||||
setElements(newElements);
|
||||
syncHistory();
|
||||
// Push to Yjs CRDT for real-time sync
|
||||
collab.setElement(el);
|
||||
// Save to backend
|
||||
if (drawingId && token) {
|
||||
setSavedStatus('Speichert…');
|
||||
createElementTyped(token, drawingId, el).then(() => {
|
||||
setSavedStatus('gespeichert');
|
||||
}).catch((err) => {
|
||||
console.error('Failed to save element:', err);
|
||||
setSavedStatus('Fehler beim Speichern');
|
||||
});
|
||||
}
|
||||
}, [elements, layers, blocks, groups, bgConfig, syncHistory, drawingId, token, collab]);
|
||||
|
||||
const handleElementsDeleted = useCallback((ids: string[]) => {
|
||||
const newElements = elements.filter((e) => !ids.includes(e.id));
|
||||
historyManagerRef.current.pushSnapshot({
|
||||
elements: newElements, layers, blocks, groups, bgConfig,
|
||||
}, `${ids.length} Element(e) gelöscht`);
|
||||
setElements(newElements);
|
||||
syncHistory();
|
||||
// Push deletions to Yjs CRDT for real-time sync
|
||||
ids.forEach(id => collab.deleteElement(id));
|
||||
// Delete from backend
|
||||
if (token) {
|
||||
setSavedStatus('Speichert…');
|
||||
Promise.all(ids.map(id => apiDeleteElement(token, id))).then(() => {
|
||||
setSavedStatus('gespeichert');
|
||||
}).catch((err) => {
|
||||
console.error('Failed to delete elements:', err);
|
||||
setSavedStatus('Fehler beim Speichern');
|
||||
});
|
||||
}
|
||||
}, [elements, layers, blocks, groups, bgConfig, syncHistory, token, collab]);
|
||||
|
||||
const handleElementsModified = useCallback((modified: CADElement[]) => {
|
||||
const newElements = elements.map((e) => {
|
||||
const found = modified.find((m) => m.id === e.id);
|
||||
return found ?? e;
|
||||
});
|
||||
historyManagerRef.current.pushSnapshot({
|
||||
elements: newElements, layers, blocks, groups, bgConfig,
|
||||
}, `${modified.length} Element(e) geändert`);
|
||||
setElements(newElements);
|
||||
syncHistory();
|
||||
// Push modifications to Yjs CRDT for real-time sync
|
||||
modified.forEach(el => collab.setElement(el));
|
||||
// Update in backend
|
||||
if (token) {
|
||||
setSavedStatus('Speichert…');
|
||||
Promise.all(modified.map(el => updateElement(token, el.id, el))).then(() => {
|
||||
setSavedStatus('gespeichert');
|
||||
}).catch((err) => {
|
||||
console.error('Failed to update elements:', err);
|
||||
setSavedStatus('Fehler beim Speichern');
|
||||
});
|
||||
}
|
||||
}, [elements, layers, blocks, groups, bgConfig, syncHistory, token, collab]);
|
||||
|
||||
const lastCursorUpdateRef = useRef(0);
|
||||
const handleCursorMoved = useCallback((x: number, y: number) => {
|
||||
const now = performance.now();
|
||||
if (now - lastCursorUpdateRef.current < 33) return; // throttle to ~30fps
|
||||
lastCursorUpdateRef.current = now;
|
||||
setCursorPos({ x, y });
|
||||
collab.setCursor(x, y);
|
||||
}, [collab]);
|
||||
|
||||
const handleToolStateChanged = useCallback((state: ToolState) => {
|
||||
setToolState(state);
|
||||
}, []);
|
||||
|
||||
const handleTextEdit = useCallback((el: CADElement) => {
|
||||
const text = window.prompt('Text eingeben:', '');
|
||||
if (text !== null && text.trim() !== '') {
|
||||
setElements((prev) => prev.map((e) =>
|
||||
e.id === el.id ? { ...e, properties: { ...e.properties, text } } : e
|
||||
));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCommandTrigger = useCallback((msg: string) => {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]);
|
||||
}, []);
|
||||
|
||||
// Block management handlers
|
||||
const handleRenameBlock = useCallback((id: string, name: string) => {
|
||||
setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, name } : b));
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block umbenannt: ${name}`, type: 'info' }]);
|
||||
}, []);
|
||||
|
||||
const handleDuplicateBlock = useCallback((id: string) => {
|
||||
setBlocks((prev) => {
|
||||
const block = prev.find((b) => b.id === id);
|
||||
if (!block) return prev;
|
||||
const newId = `blk-${Date.now()}`;
|
||||
const copy: BlockDefinition = {
|
||||
...block,
|
||||
id: newId,
|
||||
name: `${block.name} (Kopie)`,
|
||||
elements: block.elements.map((el) => ({ ...el, id: `${el.id}_copy_${Date.now()}` })),
|
||||
};
|
||||
return [...prev, copy];
|
||||
});
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block dupliziert', type: 'info' }]);
|
||||
}, []);
|
||||
|
||||
const handleDeleteBlock = useCallback((id: string) => {
|
||||
setBlocks((prev) => prev.filter((b) => b.id !== id));
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block gelöscht', type: 'info' }]);
|
||||
// Delete from backend
|
||||
if (token) {
|
||||
apiDeleteBlock(token, id).catch((err) => {
|
||||
console.error('Failed to delete block:', err);
|
||||
});
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const handleSvgImport = useCallback((svg: string, name: string, category: string) => {
|
||||
const svc = new BlockService();
|
||||
const block = svc.importSVG(svg, name, category);
|
||||
setBlocks((prev) => [...prev, block]);
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `SVG importiert: ${name}`, type: 'info' }]);
|
||||
}, []);
|
||||
|
||||
const handleSaveGroupAsBlock = useCallback((name: string) => {
|
||||
const selectedEls = selectedElement ? [selectedElement] : [];
|
||||
setBlocks((prev) => {
|
||||
const newId = `blk_grp_${Date.now()}`;
|
||||
const block: BlockDefinition = {
|
||||
id: newId,
|
||||
name,
|
||||
description: 'Aus Auswahl erstellt',
|
||||
category: 'Custom',
|
||||
elements: selectedEls.map(el => ({ ...el, id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` })),
|
||||
};
|
||||
return [...prev, block];
|
||||
});
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block erstellt: ${name} (${selectedEls.length} Elemente)`, type: 'info' }]);
|
||||
}, [selectedElement]);
|
||||
|
||||
const handleBlockCategoryChange = useCallback((cat: string) => {
|
||||
setBlockCategory(cat);
|
||||
}, []);
|
||||
|
||||
const handleTemplateSelect = useCallback((templateName: string | null) => {
|
||||
setSelectedTemplate(templateName);
|
||||
if (templateName) {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Vorlage gewählt: ${templateName} – Klicken zum Platzieren`, type: 'info' }]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBlockSearch = useCallback((_query: string) => {
|
||||
// Search is handled locally in BlockLibrary component
|
||||
}, []);
|
||||
|
||||
const handleDragBlock = useCallback((blockId: string) => {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block gezogen: ${blockId}`, type: 'info' }]);
|
||||
}, []);
|
||||
|
||||
const handleBlockDrop = useCallback((blockId: string, x: number, y: number) => {
|
||||
const svc = new BlockService();
|
||||
blocks.forEach(b => svc.addBlock(b));
|
||||
const instance = svc.createInstance(blockId, x, y, activeLayerId);
|
||||
if (instance) {
|
||||
setElements((prev) => [...prev, instance]);
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block platziert: ${blockId} bei (${x.toFixed(2)}, ${y.toFixed(2)})`, type: 'info' }]);
|
||||
}
|
||||
}, [blocks, activeLayerId]);
|
||||
|
||||
const handleSelectionChange = useCallback((selectedIds: string[]) => {
|
||||
if (selectedIds.length === 1) {
|
||||
const el = elements.find(e => e.id === selectedIds[0]);
|
||||
setSelectedElement(el ?? null);
|
||||
} else {
|
||||
setSelectedElement(null);
|
||||
}
|
||||
}, [elements]);
|
||||
|
||||
const handleImport = useCallback(async (file: File) => {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiere ${file.name}...`, type: 'info' }]);
|
||||
const result = await importFile(file);
|
||||
if (result.success) {
|
||||
setElements((prev) => [...prev, ...result.elements]);
|
||||
if (result.layers && result.layers.length > 0) {
|
||||
setLayers((prev) => {
|
||||
const existingIds = new Set(prev.map(l => l.id));
|
||||
const newLayers = result.layers!.filter(l => !existingIds.has(l.id));
|
||||
return [...prev, ...newLayers];
|
||||
});
|
||||
}
|
||||
if (result.blocks && result.blocks.length > 0) {
|
||||
setBlocks((prev) => [...prev, ...result.blocks!]);
|
||||
}
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiert: ${result.elements.length} Elemente aus ${file.name}`, type: 'info' }]);
|
||||
if (result.warnings.length > 0) {
|
||||
result.warnings.forEach(w => setCommandHistory((prev) => [...prev, { prefix: '·', text: w, type: 'info' }]));
|
||||
}
|
||||
} else {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Import fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback(async (format: ExportFormat) => {
|
||||
const projectData: ProjectData = {
|
||||
version: '1.0',
|
||||
name: projectName,
|
||||
layers,
|
||||
elements,
|
||||
blocks,
|
||||
};
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiere als ${format.toUpperCase()}...`, type: 'info' }]);
|
||||
const result = await exportProject(projectData, { format });
|
||||
if (result.success && result.blob) {
|
||||
downloadBlob(result.blob, result.filename);
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiert: ${result.filename}`, type: 'info' }]);
|
||||
} else {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Export fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]);
|
||||
}
|
||||
}, [layers, elements, blocks]);
|
||||
|
||||
const handleRibbonAction = useCallback((action: string) => {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '›', text: action, type: 'command' }]);
|
||||
if (action === 'background-import') {
|
||||
setBgImportOpen(true);
|
||||
}
|
||||
if (action === 'history') {
|
||||
setHistoryPanelOpen((prev) => !prev);
|
||||
}
|
||||
if (action === 'undo') {
|
||||
handleUndo();
|
||||
}
|
||||
if (action === 'redo') {
|
||||
handleRedo();
|
||||
}
|
||||
if (action === 'import') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.dxf,.svg,.json';
|
||||
input.onchange = () => {
|
||||
if (input.files && input.files[0]) {
|
||||
handleImport(input.files[0]);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
if (action === 'export') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.setAttribute('nwsave', '');
|
||||
input.accept = '.dxf,.svg,.pdf,.png,.json';
|
||||
input.onchange = () => {
|
||||
const name = input.value || 'cad-export';
|
||||
const ext = name.split('.').pop()?.toLowerCase() as ExportFormat;
|
||||
if (ext && ['dxf', 'svg', 'pdf', 'png', 'json'].includes(ext)) {
|
||||
handleExport(ext);
|
||||
} else {
|
||||
// Default to DXF if no valid extension
|
||||
handleExport('dxf');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
}, [handleUndo, handleRedo, handleImport, handleExport]);
|
||||
|
||||
const handleToolChange = useCallback((tool: string) => {
|
||||
setActiveTool(tool);
|
||||
setMobileLeftOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleViewChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
}, []);
|
||||
|
||||
const handleToggleGrid = useCallback(() => setGridEnabled((p) => !p), []);
|
||||
const handleToggleOrtho = useCallback(() => setOrthoEnabled((p) => !p), []);
|
||||
const handleToggleSnap = useCallback(() => setSnapEnabled((p) => !p), []);
|
||||
const handleTogglePolar = useCallback(() => setPolarEnabled((p) => !p), []);
|
||||
|
||||
// Layer handlers
|
||||
const handleSelectLayer = useCallback((id: string) => setActiveLayerId(id), []);
|
||||
const handleAddLayer = useCallback(() => {
|
||||
setLayers((prev) => {
|
||||
const newId = `layer-${Date.now()}`;
|
||||
const newLayer: CADLayer = {
|
||||
id: newId, name: `Layer ${prev.length + 1}`, visible: true, locked: false,
|
||||
color: '#ffffff', lineType: 'solid', transparency: 0,
|
||||
sortOrder: prev.length, parentId: null,
|
||||
};
|
||||
// Save to backend
|
||||
if (drawingId && token) {
|
||||
createLayerTyped(token, drawingId, newLayer).catch((err) => {
|
||||
console.error('Failed to save layer:', err);
|
||||
});
|
||||
}
|
||||
return [...prev, newLayer];
|
||||
});
|
||||
}, [drawingId, token]);
|
||||
const handleToggleLayer = useCallback((id: string) => {
|
||||
setLayers((prev) => prev.map((l) => l.id === id ? { ...l, visible: !l.visible } : l));
|
||||
}, []);
|
||||
const handleDeleteLayer = useCallback((id: string) => {
|
||||
setLayers((prev) => prev.filter((l) => l.id !== id));
|
||||
// Delete from backend
|
||||
if (token) {
|
||||
apiDeleteLayer(token, id).catch((err) => {
|
||||
console.error('Failed to delete layer:', err);
|
||||
});
|
||||
}
|
||||
}, [token]);
|
||||
const handleRenameLayer = useCallback((id: string, name: string) => {
|
||||
setLayers((prev) => prev.map((l) => l.id === id ? { ...l, name } : l));
|
||||
}, []);
|
||||
const handleDuplicateLayer = useCallback((id: string) => {
|
||||
setLayers((prev) => {
|
||||
const layer = prev.find((l) => l.id === id);
|
||||
if (!layer) return prev;
|
||||
const newId = `layer-${Date.now()}`;
|
||||
const copy: CADLayer = { ...layer, id: newId, name: `${layer.name} (Kopie)`, sortOrder: prev.length };
|
||||
return [...prev, copy];
|
||||
});
|
||||
}, []);
|
||||
const handleToggleLock = useCallback((id: string) => {
|
||||
setLayers((prev) => prev.map((l) => l.id === id ? { ...l, locked: !l.locked } : l));
|
||||
}, []);
|
||||
const handleUpdateLayerColor = useCallback((id: string, color: string) => {
|
||||
setLayers((prev) => prev.map((l) => l.id === id ? { ...l, color } : l));
|
||||
}, []);
|
||||
const handleUpdateLayerLineType = useCallback((id: string, lineType: 'solid' | 'dashed' | 'dotted') => {
|
||||
setLayers((prev) => prev.map((l) => l.id === id ? { ...l, lineType } : l));
|
||||
}, []);
|
||||
const handleUpdateLayerTransparency = useCallback((id: string, transparency: number) => {
|
||||
setLayers((prev) => prev.map((l) => l.id === id ? { ...l, transparency } : l));
|
||||
}, []);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: vergrößert', type: 'info' }]);
|
||||
}, []);
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: verkleinert', type: 'info' }]);
|
||||
}, []);
|
||||
const handleZoomFit = useCallback(() => {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]);
|
||||
}, []);
|
||||
|
||||
const handleBgApply = useCallback((config: BackgroundConfig, _image: HTMLImageElement | null) => {
|
||||
setBgConfig(config);
|
||||
setBgImportOpen(false);
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Hintergrund geladen: ${config.name} (${config.width}×${config.height}px, Maßstab: ${config.scale.toFixed(3)} px/mm)`, type: 'info' }]);
|
||||
}, []);
|
||||
|
||||
const handleCommand = useCallback((cmd: string) => {
|
||||
const upper = cmd.trim().toUpperCase();
|
||||
setCommandHistory((prev) => [...prev, { prefix: '›', text: cmd, type: 'command' }]);
|
||||
|
||||
const registry = getCommandRegistry();
|
||||
|
||||
if (upper === 'UNDO' || upper === 'U') {
|
||||
handleUndo();
|
||||
return;
|
||||
}
|
||||
if (upper === 'REDO') {
|
||||
handleRedo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Group command: create group from currently selected elements
|
||||
if (upper === 'GROUP' || upper === 'GRP') {
|
||||
const gm = groupManagerRef.current;
|
||||
// For now, group all elements (selection state is in InteractionEngine, not accessible here)
|
||||
// In a full implementation, we'd need selected IDs from the interaction engine
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe erstellt (Auswahl im Canvas erforderlich)', type: 'info' }]);
|
||||
setGroups(gm.getGroups());
|
||||
return;
|
||||
}
|
||||
|
||||
// Ungroup command
|
||||
if (upper === 'UNG' || upper === 'UNGROUP') {
|
||||
const gm = groupManagerRef.current;
|
||||
const allGroups = gm.getGroups();
|
||||
if (allGroups.length > 0) {
|
||||
gm.ungroup(allGroups[allGroups.length - 1].id);
|
||||
setGroups(gm.getGroups());
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe aufgelöst', type: 'info' }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Import command — trigger file dialog
|
||||
if (upper === 'IMPORT' || upper === 'IMP' || upper === 'I') {
|
||||
handleRibbonAction('import');
|
||||
return;
|
||||
}
|
||||
// Export command — trigger export dialog
|
||||
if (upper === 'EXPORT' || upper === 'EXP' || upper === 'EX') {
|
||||
handleRibbonAction('export');
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = registry.getToolId(upper);
|
||||
if (tool) {
|
||||
setActiveTool(tool);
|
||||
setMobileLeftOpen(false);
|
||||
const label = registry.getLabel(upper) ?? `Werkzeug: ${tool}`;
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: label, type: 'info' }]);
|
||||
} else {
|
||||
// Check plugin commands
|
||||
const pluginCmds = pluginRegistry.getCommandExtensions();
|
||||
const parts = cmd.trim().split(/\s+/);
|
||||
const cmdName = parts[0].toUpperCase();
|
||||
const pluginCmd = pluginCmds.find((c) => c.name.toUpperCase() === cmdName);
|
||||
if (pluginCmd) {
|
||||
const ctx: PluginContext = {
|
||||
addElement: (el) => setElements((prev) => [...prev, el]),
|
||||
removeElement: (id) => setElements((prev) => prev.filter((e) => e.id !== id)),
|
||||
updateElement: (id, props) => setElements((prev) => prev.map((e) => (e.id === id ? { ...e, ...props } : e))),
|
||||
getElements: () => elements,
|
||||
getLayers: () => layers,
|
||||
getActiveLayerId: () => activeLayerId,
|
||||
showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]),
|
||||
log: (msg) => console.log(`[Plugin] ${msg}`),
|
||||
};
|
||||
pluginCmd.execute(parts.slice(1), ctx);
|
||||
} else {
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Unbekannter Befehl: ${cmd}`, type: 'info' }]);
|
||||
}
|
||||
}
|
||||
}, [handleUndo, handleRedo, handleRibbonAction]);
|
||||
|
||||
const handleKISend = useCallback(async (text: string) => {
|
||||
const userMsg: KIMessage = { id: `ki-${Date.now()}`, role: 'user', content: text };
|
||||
const pendingId = `ki-${Date.now() + 1}`;
|
||||
const pendingMsg: KIMessage = { id: pendingId, role: 'assistant', content: '…' };
|
||||
setKIMessages((prev) => [...prev, userMsg, pendingMsg]);
|
||||
setKiLoading(true);
|
||||
|
||||
try {
|
||||
// Build CAD context
|
||||
const elementTypeSummary: Record<string, number> = {};
|
||||
for (const el of elements) {
|
||||
elementTypeSummary[el.type] = (elementTypeSummary[el.type] || 0) + 1;
|
||||
}
|
||||
const context = {
|
||||
projectName,
|
||||
elementCount: elements.length,
|
||||
layerCount: layers.length,
|
||||
elementTypeSummary,
|
||||
};
|
||||
|
||||
// Build message history (last 10 messages)
|
||||
const history = kiMessages.slice(-10).map((m) => ({
|
||||
role: m.role,
|
||||
content: typeof m.content === 'string' ? m.content : String(m.content),
|
||||
}));
|
||||
history.push({ role: 'user', content: text });
|
||||
|
||||
const result = await aiChat(token, history, context);
|
||||
|
||||
setKIMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === pendingId
|
||||
? { ...m, content: result.content }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
setKIMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === pendingId
|
||||
? { ...m, content: `Fehler: ${err.message || 'KI-Anfrage fehlgeschlagen'}` }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setKiLoading(false);
|
||||
}
|
||||
}, [token, projectName, elements, layers, kiMessages]);
|
||||
|
||||
const handleSuggestionClick = useCallback((suggestion: KISuggestion) => {
|
||||
handleKISend(suggestion.label);
|
||||
}, [handleKISend]);
|
||||
|
||||
const activeLayerName = layers.find((l) => l.id === 'layer-0')?.name ?? '—';
|
||||
|
||||
// ─── Render ─────────────────────────────────────────────
|
||||
return (
|
||||
<div className={`app ${theme}`} data-theme={theme}>
|
||||
<Topbar
|
||||
projectName={projectName}
|
||||
savedStatus={savedStatus}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onThemeToggle={handleThemeToggle}
|
||||
theme={theme}
|
||||
/>
|
||||
<RibbonBar
|
||||
activeTab={activeRibbonTab}
|
||||
onTabChange={setActiveRibbonTab}
|
||||
onAction={handleRibbonAction}
|
||||
/>
|
||||
<div className="app-body">
|
||||
<LeftSidebar
|
||||
activeTool={activeTool}
|
||||
onToolChange={handleToolChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
onTemplateSelect={handleTemplateSelect}
|
||||
/>
|
||||
<CanvasArea
|
||||
cursorPos={cursorPos}
|
||||
viewMode={viewMode}
|
||||
onViewChange={handleViewChange}
|
||||
gridEnabled={gridEnabled}
|
||||
orthoEnabled={orthoEnabled}
|
||||
snapEnabled={snapEnabled}
|
||||
polarEnabled={polarEnabled}
|
||||
activeTool={activeTool}
|
||||
elements={elements}
|
||||
layers={layers}
|
||||
onElementCreated={handleElementCreated}
|
||||
onElementsDeleted={handleElementsDeleted}
|
||||
onElementsModified={handleElementsModified}
|
||||
onCursorMoved={handleCursorMoved}
|
||||
onToolStateChanged={handleToolStateChanged}
|
||||
onToggleGrid={handleToggleGrid}
|
||||
onToggleOrtho={handleToggleOrtho}
|
||||
onToggleSnap={handleToggleSnap}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onZoomFit={handleZoomFit}
|
||||
onTextEdit={handleTextEdit}
|
||||
onCommandTrigger={handleCommandTrigger}
|
||||
blocks={blocks}
|
||||
onBlockDrop={handleBlockDrop}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
bgConfig={bgConfig}
|
||||
remoteCursors={collab.cursors}
|
||||
/>
|
||||
<RightSidebar
|
||||
activePanel={activeRightPanel}
|
||||
onPanelChange={setActiveRightPanel}
|
||||
selectedElement={selectedElement}
|
||||
layers={layers}
|
||||
blocks={blocks}
|
||||
activeLayerId={activeLayerId}
|
||||
onSelectLayer={handleSelectLayer}
|
||||
onAddLayer={handleAddLayer}
|
||||
onToggleLayer={handleToggleLayer}
|
||||
onDeleteLayer={handleDeleteLayer}
|
||||
onRenameLayer={handleRenameLayer}
|
||||
onDuplicateLayer={handleDuplicateLayer}
|
||||
onToggleLock={handleToggleLock}
|
||||
onUpdateLayerColor={handleUpdateLayerColor}
|
||||
onUpdateLayerLineType={handleUpdateLayerLineType}
|
||||
onUpdateLayerTransparency={handleUpdateLayerTransparency}
|
||||
onRenameBlock={handleRenameBlock}
|
||||
onDuplicateBlock={handleDuplicateBlock}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onSvgImport={handleSvgImport}
|
||||
onSaveGroupAsBlock={handleSaveGroupAsBlock}
|
||||
onBlockCategoryChange={handleBlockCategoryChange}
|
||||
onBlockSearch={handleBlockSearch}
|
||||
onDragBlock={handleDragBlock}
|
||||
kiMessages={kiMessages}
|
||||
kiSuggestions={kiSuggestions}
|
||||
onKISend={handleKISend}
|
||||
onKISuggestionClick={handleSuggestionClick}
|
||||
kiLoading={kiLoading}
|
||||
/>
|
||||
</div>
|
||||
<CommandLine
|
||||
history={commandHistory}
|
||||
onCommand={handleCommand}
|
||||
/>
|
||||
<StatusBar
|
||||
snapEnabled={snapEnabled}
|
||||
orthoEnabled={orthoEnabled}
|
||||
polarEnabled={polarEnabled}
|
||||
gridEnabled={gridEnabled}
|
||||
cursorX={cursorPos.x}
|
||||
cursorY={cursorPos.y}
|
||||
activeLayer={activeLayerName}
|
||||
activeTool={activeTool}
|
||||
onlineCount={onlineCount}
|
||||
onToggleSnap={handleToggleSnap}
|
||||
onToggleOrtho={handleToggleOrtho}
|
||||
onTogglePolar={handleTogglePolar}
|
||||
onToggleGrid={handleToggleGrid}
|
||||
seatCount={seatCount}
|
||||
/>
|
||||
<MobileDrawers
|
||||
leftOpen={mobileLeftOpen}
|
||||
rightOpen={mobileRightOpen}
|
||||
activeRightTab={activeDrawerTab}
|
||||
onCloseLeft={() => setMobileLeftOpen(false)}
|
||||
onCloseRight={() => setMobileRightOpen(false)}
|
||||
onRightTabChange={setActiveDrawerTab}
|
||||
/>
|
||||
<BackgroundImport
|
||||
open={bgImportOpen}
|
||||
onClose={() => setBgImportOpen(false)}
|
||||
onApply={handleBgApply}
|
||||
backgroundService={bgServiceRef.current}
|
||||
/>
|
||||
{historyPanelOpen && (
|
||||
<HistoryPanel
|
||||
entries={historyEntries}
|
||||
onJumpTo={(entryId: string) => {
|
||||
const snap = historyManagerRef.current.jumpTo(entryId);
|
||||
if (snap) {
|
||||
restoreSnapshot(snap);
|
||||
syncHistory();
|
||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Historie: zu „${snap.label}“ gesprungen`, type: 'info' }]);
|
||||
}
|
||||
}}
|
||||
onClose={() => setHistoryPanelOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── App Wrapper (Auth Gate) ───────────────────────────
|
||||
const App: React.FC = () => {
|
||||
const { user, token } = useAuth();
|
||||
const [authView, setAuthView] = useState<'login' | 'register'>('login');
|
||||
const [openedProjectId, setOpenedProjectId] = useState<string | null>(null);
|
||||
|
||||
// Not authenticated → show Login or Register
|
||||
if (!user || !token) {
|
||||
return authView === 'login'
|
||||
? <Login onSwitchToRegister={() => setAuthView('register')} />
|
||||
: <Register onSwitchToLogin={() => setAuthView('login')} />;
|
||||
}
|
||||
|
||||
// Authenticated but no project opened → show Dashboard
|
||||
if (!openedProjectId) {
|
||||
return <Dashboard onOpenProject={(id) => setOpenedProjectId(id)} />;
|
||||
}
|
||||
|
||||
// Authenticated + project opened → show CAD Editor
|
||||
return <CADEditor projectId={openedProjectId} token={token} />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { CADLayer } from '../types/cad.types';
|
||||
|
||||
export class LayerManager {
|
||||
private layers: Map<string, CADLayer> = new Map();
|
||||
private activeLayerId: string = '';
|
||||
|
||||
addLayer(layer: CADLayer): void {
|
||||
this.layers.set(layer.id, layer);
|
||||
if (!this.activeLayerId) this.activeLayerId = layer.id;
|
||||
}
|
||||
|
||||
removeLayer(id: string): void {
|
||||
this.layers.delete(id);
|
||||
if (this.activeLayerId === id) {
|
||||
const first = this.layers.keys().next();
|
||||
this.activeLayerId = first.done ? '' : first.value;
|
||||
}
|
||||
}
|
||||
|
||||
getLayer(id: string): CADLayer | undefined {
|
||||
return this.layers.get(id);
|
||||
}
|
||||
|
||||
getLayers(): CADLayer[] {
|
||||
return Array.from(this.layers.values()).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
|
||||
getVisibleLayers(): CADLayer[] {
|
||||
return this.getLayers().filter(l => l.visible && !l.locked);
|
||||
}
|
||||
|
||||
setActiveLayer(id: string): void {
|
||||
if (this.layers.has(id)) this.activeLayerId = id;
|
||||
}
|
||||
|
||||
getActiveLayer(): CADLayer | undefined {
|
||||
return this.layers.get(this.activeLayerId);
|
||||
}
|
||||
|
||||
getActiveLayerId(): string {
|
||||
return this.activeLayerId;
|
||||
}
|
||||
|
||||
toggleVisibility(id: string): void {
|
||||
const layer = this.layers.get(id);
|
||||
if (layer) layer.visible = !layer.visible;
|
||||
}
|
||||
|
||||
toggleLock(id: string): void {
|
||||
const layer = this.layers.get(id);
|
||||
if (layer) layer.locked = !layer.locked;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.layers.clear();
|
||||
this.activeLayerId = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a layer.
|
||||
*/
|
||||
renameLayer(id: string, name: string): void {
|
||||
const layer = this.layers.get(id);
|
||||
if (layer) layer.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update layer properties.
|
||||
*/
|
||||
updateLayer(id: string, props: Partial<CADLayer>): void {
|
||||
const layer = this.layers.get(id);
|
||||
if (layer) {
|
||||
Object.assign(layer, props);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set parent for a layer (tree structure).
|
||||
*/
|
||||
setParent(id: string, parentId: string | null): void {
|
||||
const layer = this.layers.get(id);
|
||||
if (!layer) return;
|
||||
// Prevent circular references
|
||||
if (parentId) {
|
||||
let current: string | null = parentId;
|
||||
while (current) {
|
||||
if (current === id) return; // Would create a cycle
|
||||
const parent = this.layers.get(current);
|
||||
if (!parent) break;
|
||||
current = parent.parentId;
|
||||
}
|
||||
}
|
||||
layer.parentId = parentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get child layers of a parent.
|
||||
*/
|
||||
getChildLayers(parentId: string | null): CADLayer[] {
|
||||
return this.getLayers().filter(l => l.parentId === parentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layer tree structure.
|
||||
*/
|
||||
getLayerTree(): Array<CADLayer & { children: CADLayer[] }> {
|
||||
const buildTree = (parentId: string | null): Array<CADLayer & { children: CADLayer[] }> => {
|
||||
return this.getChildLayers(parentId).map(layer => ({
|
||||
...layer,
|
||||
children: buildTree(layer.id),
|
||||
}));
|
||||
};
|
||||
return buildTree(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move layer to a new position in the sort order.
|
||||
*/
|
||||
moveLayer(id: string, newSortOrder: number): void {
|
||||
const layer = this.layers.get(id);
|
||||
if (!layer) return;
|
||||
layer.sortOrder = newSortOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a layer with all its properties.
|
||||
*/
|
||||
duplicateLayer(id: string): CADLayer | null {
|
||||
const layer = this.layers.get(id);
|
||||
if (!layer) return null;
|
||||
const newId = `layer_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const copy: CADLayer = {
|
||||
...layer,
|
||||
id: newId,
|
||||
name: `${layer.name} (Kopie)`,
|
||||
sortOrder: layer.sortOrder + 1,
|
||||
parentId: layer.parentId,
|
||||
};
|
||||
this.layers.set(newId, copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter layers by criteria.
|
||||
*/
|
||||
filterLayers(criteria: {
|
||||
visible?: boolean;
|
||||
locked?: boolean;
|
||||
color?: string;
|
||||
lineType?: string;
|
||||
nameContains?: string;
|
||||
}): CADLayer[] {
|
||||
return this.getLayers().filter(l => {
|
||||
if (criteria.visible !== undefined && l.visible !== criteria.visible) return false;
|
||||
if (criteria.locked !== undefined && l.locked !== criteria.locked) return false;
|
||||
if (criteria.color && l.color !== criteria.color) return false;
|
||||
if (criteria.lineType && l.lineType !== criteria.lineType) return false;
|
||||
if (criteria.nameContains && !l.name.toLowerCase().includes(criteria.nameContains.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all descendant layer IDs (children, grandchildren, etc.).
|
||||
*/
|
||||
getDescendantIds(id: string): string[] {
|
||||
const result: string[] = [];
|
||||
const collect = (parentId: string) => {
|
||||
for (const layer of this.layers.values()) {
|
||||
if (layer.parentId === parentId) {
|
||||
result.push(layer.id);
|
||||
collect(layer.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(id);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a layer is locked.
|
||||
*/
|
||||
isLocked(id: string): boolean {
|
||||
const layer = this.layers.get(id);
|
||||
return layer ? layer.locked : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layer color.
|
||||
*/
|
||||
getLayerColor(id: string): string | undefined {
|
||||
const layer = this.layers.get(id);
|
||||
return layer?.color;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
import type {
|
||||
CADElement, CADLayer, CADProperties, BoundingBox, Viewport,
|
||||
} from '../types/cad.types';
|
||||
import { ZoomPanController } from './ZoomPanController';
|
||||
import { SpatialIndex } from './SpatialIndex';
|
||||
import { LayerManager } from './LayerManager';
|
||||
import { pluginRegistry } from '../plugins';
|
||||
|
||||
export interface RenderOptions {
|
||||
showGrid: boolean;
|
||||
gridSize: number;
|
||||
showSnapPoints: boolean;
|
||||
showOrtho: boolean;
|
||||
orthoAngle: number;
|
||||
backgroundSrc?: string;
|
||||
backgroundScale: number;
|
||||
backgroundOffsetX: number;
|
||||
backgroundOffsetY: number;
|
||||
backgroundRotation: number;
|
||||
backgroundOpacity: number;
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
selectedIds: Set<string>;
|
||||
hoverId: string | null;
|
||||
boxStart: { x: number; y: number } | null;
|
||||
boxEnd: { x: number; y: number } | null;
|
||||
}
|
||||
|
||||
export interface SnapPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
type: 'endpoint' | 'midpoint' | 'center' | 'intersection' | 'nearest';
|
||||
}
|
||||
|
||||
export class RenderEngine {
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private zoomPan: ZoomPanController;
|
||||
private spatialIndex: SpatialIndex;
|
||||
private layerManager: LayerManager;
|
||||
private options: RenderOptions;
|
||||
private selection: SelectionState;
|
||||
private snapPoints: SnapPoint[] = [];
|
||||
private activeSnapPoint: SnapPoint | null = null;
|
||||
private dpr = 1;
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
zoomPan: ZoomPanController,
|
||||
spatialIndex: SpatialIndex,
|
||||
layerManager: LayerManager,
|
||||
) {
|
||||
this.canvas = canvas;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas 2D context not available');
|
||||
this.ctx = ctx;
|
||||
this.zoomPan = zoomPan;
|
||||
this.spatialIndex = spatialIndex;
|
||||
this.layerManager = layerManager;
|
||||
this.options = {
|
||||
showGrid: true,
|
||||
gridSize: 20,
|
||||
showSnapPoints: false,
|
||||
showOrtho: false,
|
||||
orthoAngle: 0,
|
||||
backgroundScale: 1,
|
||||
backgroundOffsetX: 0,
|
||||
backgroundOffsetY: 0,
|
||||
backgroundRotation: 0,
|
||||
backgroundOpacity: 0.5,
|
||||
};
|
||||
this.selection = {
|
||||
selectedIds: new Set(),
|
||||
hoverId: null,
|
||||
boxStart: null,
|
||||
boxEnd: null,
|
||||
};
|
||||
}
|
||||
|
||||
setOptions(opts: Partial<RenderOptions>): void {
|
||||
this.options = { ...this.options, ...opts };
|
||||
}
|
||||
|
||||
getOptions(): RenderOptions {
|
||||
return { ...this.options };
|
||||
}
|
||||
|
||||
setSelection(sel: Partial<SelectionState>): void {
|
||||
this.selection = { ...this.selection, ...sel };
|
||||
}
|
||||
|
||||
getSelection(): SelectionState {
|
||||
return { ...this.selection };
|
||||
}
|
||||
|
||||
setSnapPoints(points: SnapPoint[]): void {
|
||||
this.snapPoints = points;
|
||||
}
|
||||
|
||||
setActiveSnapPoint(pt: SnapPoint | null): void {
|
||||
this.activeSnapPoint = pt;
|
||||
}
|
||||
|
||||
resize(width: number, height: number): void {
|
||||
this.dpr = window.devicePixelRatio || 1;
|
||||
this.canvas.width = width * this.dpr;
|
||||
this.canvas.height = height * this.dpr;
|
||||
this.canvas.style.width = width + 'px';
|
||||
this.canvas.style.height = height + 'px';
|
||||
this.ctx.scale(this.dpr, this.dpr);
|
||||
}
|
||||
|
||||
setLayers(layers: CADLayer[]): void {
|
||||
this.layerManager.clear();
|
||||
for (const layer of layers) {
|
||||
this.layerManager.addLayer(layer);
|
||||
}
|
||||
}
|
||||
|
||||
private blockDefinitions: Map<string, { elements: CADElement[] }> = new Map();
|
||||
|
||||
setBlockDefinitions(blocks: Array<{ id: string; elements: CADElement[] }>): void {
|
||||
this.blockDefinitions.clear();
|
||||
for (const b of blocks) {
|
||||
this.blockDefinitions.set(b.id, b);
|
||||
}
|
||||
}
|
||||
|
||||
render(): void {
|
||||
const w = this.canvas.width / this.dpr;
|
||||
const h = this.canvas.height / this.dpr;
|
||||
this.ctx.save();
|
||||
this.ctx.fillStyle = '#1e1e2e';
|
||||
this.ctx.fillRect(0, 0, w, h);
|
||||
|
||||
if (this.options.showGrid) this.drawGrid(w, h);
|
||||
if (this.options.backgroundSrc) this.drawBackground();
|
||||
|
||||
const viewport = this.zoomPan.getViewport();
|
||||
const visibleElements = this.spatialIndex.search({
|
||||
minX: viewport.minX, minY: viewport.minY,
|
||||
maxX: viewport.maxX, maxY: viewport.maxY,
|
||||
});
|
||||
|
||||
const layers = this.layerManager.getLayers();
|
||||
for (const layer of layers) {
|
||||
if (!layer.visible) continue;
|
||||
const els = visibleElements.filter(e => e.layerId === layer.id);
|
||||
for (const el of els) {
|
||||
this.drawElement(el, layer);
|
||||
}
|
||||
}
|
||||
|
||||
this.drawSelectionBox();
|
||||
if (this.options.showSnapPoints) this.drawSnapPoints();
|
||||
if (this.options.showOrtho) this.drawOrtho();
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
private drawGrid(w: number, h: number): void {
|
||||
const scale = this.zoomPan.getScale();
|
||||
const gridSize = this.options.gridSize * scale;
|
||||
if (gridSize < 4) return;
|
||||
const offsetX = this.zoomPan.getTransform().e % gridSize;
|
||||
const offsetY = this.zoomPan.getTransform().f % gridSize;
|
||||
|
||||
this.ctx.strokeStyle = '#2a2a3e';
|
||||
this.ctx.lineWidth = 1;
|
||||
this.ctx.beginPath();
|
||||
for (let x = offsetX; x < w; x += gridSize) {
|
||||
this.ctx.moveTo(x, 0);
|
||||
this.ctx.lineTo(x, h);
|
||||
}
|
||||
for (let y = offsetY; y < h; y += gridSize) {
|
||||
this.ctx.moveTo(0, y);
|
||||
this.ctx.lineTo(w, y);
|
||||
}
|
||||
this.ctx.stroke();
|
||||
|
||||
// Major grid lines every 5 cells
|
||||
const majorSize = gridSize * 5;
|
||||
if (majorSize >= 20) {
|
||||
const majOffX = this.zoomPan.getTransform().e % majorSize;
|
||||
const majOffY = this.zoomPan.getTransform().f % majorSize;
|
||||
this.ctx.strokeStyle = '#33334a';
|
||||
this.ctx.beginPath();
|
||||
for (let x = majOffX; x < w; x += majorSize) {
|
||||
this.ctx.moveTo(x, 0);
|
||||
this.ctx.lineTo(x, h);
|
||||
}
|
||||
for (let y = majOffY; y < h; y += majorSize) {
|
||||
this.ctx.moveTo(0, y);
|
||||
this.ctx.lineTo(w, y);
|
||||
}
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
private drawBackground(): void {
|
||||
// Background image rendering with transform
|
||||
const img = new Image();
|
||||
img.src = this.options.backgroundSrc!;
|
||||
if (!img.complete) {
|
||||
img.onload = () => this.render();
|
||||
return;
|
||||
}
|
||||
this.ctx.save();
|
||||
this.ctx.globalAlpha = this.options.backgroundOpacity;
|
||||
const s = this.zoomPan.getScale();
|
||||
const ox = this.zoomPan.getTransform().e;
|
||||
const oy = this.zoomPan.getTransform().f;
|
||||
const bx = this.options.backgroundOffsetX * s + ox;
|
||||
const by = this.options.backgroundOffsetY * s + oy;
|
||||
const bw = img.width * this.options.backgroundScale * s;
|
||||
const bh = img.height * this.options.backgroundScale * s;
|
||||
this.ctx.translate(bx + bw / 2, by + bh / 2);
|
||||
this.ctx.rotate(this.options.backgroundRotation);
|
||||
this.ctx.drawImage(img, -bw / 2, -bh / 2, bw, bh);
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
private drawElement(el: CADElement, layer: CADLayer): void {
|
||||
const ctx = this.ctx;
|
||||
const s = this.zoomPan.getScale();
|
||||
const ox = this.zoomPan.getTransform().e;
|
||||
const oy = this.zoomPan.getTransform().f;
|
||||
const sx = (val: number) => val * s + ox;
|
||||
const sy = (val: number) => val * s + oy;
|
||||
|
||||
const stroke = el.properties.stroke || layer.color;
|
||||
const strokeWidth = (el.properties.strokeWidth || 1) * s;
|
||||
const fill = el.properties.fill;
|
||||
const isSelected = this.selection.selectedIds.has(el.id);
|
||||
const isHover = this.selection.hoverId === el.id;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = Math.max(0.5, strokeWidth);
|
||||
if (layer.lineType === 'dashed') ctx.setLineDash([8, 4]);
|
||||
if (layer.lineType === 'dotted') ctx.setLineDash([2, 4]);
|
||||
if (isSelected) {
|
||||
ctx.strokeStyle = '#00aaff';
|
||||
ctx.lineWidth = Math.max(1, strokeWidth + 1);
|
||||
} else if (isHover) {
|
||||
ctx.strokeStyle = '#ffaa00';
|
||||
}
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': this.drawLine(el, sx, sy); break;
|
||||
case 'rect': this.drawRect(el, sx, sy); break;
|
||||
case 'circle': this.drawCircle(el, sx, sy, s); break;
|
||||
case 'arc': this.drawArc(el, sx, sy, s); break;
|
||||
case 'polyline': this.drawPolyline(el, sx, sy); break;
|
||||
case 'polygon': this.drawPolygon(el, sx, sy, fill); break;
|
||||
case 'text': this.drawText(el, sx, sy, s); break;
|
||||
case 'dimension': this.drawDimension(el, sx, sy, s); break;
|
||||
case 'block_instance': this.drawBlockInstance(el, sx, sy, s); break;
|
||||
case 'chair': this.drawChair(el, sx, sy, s); break;
|
||||
case 'table': this.drawTable(el, sx, sy, s); break;
|
||||
case 'stage': this.drawStage(el, sx, sy, s); break;
|
||||
case 'leader': this.drawLeader(el, sx, sy, s); break;
|
||||
case 'revcloud': this.drawRevCloud(el, sx, sy, s); break;
|
||||
default: {
|
||||
// Plugin element types
|
||||
const ext = pluginRegistry.getElementType(el.type);
|
||||
if (ext?.render) {
|
||||
ext.render(this.ctx, el, s);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) this.drawSelectionHandles(el, sx, sy, s);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawLine(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number): void {
|
||||
const p = el.properties;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(sx(p.x1 ?? el.x), sy(p.y1 ?? el.y));
|
||||
this.ctx.lineTo(sx(p.x2 ?? el.x + el.width), sy(p.y2 ?? el.y + el.height));
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private drawRect(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number): void {
|
||||
const x = sx(el.x - el.width / 2);
|
||||
const y = sy(el.y - el.height / 2);
|
||||
const w = el.width * (this.zoomPan.getScale());
|
||||
const h = el.height * (this.zoomPan.getScale());
|
||||
if (el.properties.fill) {
|
||||
this.ctx.fillStyle = el.properties.fill;
|
||||
this.ctx.fillRect(x, y, w, h);
|
||||
}
|
||||
if (el.properties.hatch) {
|
||||
this.drawHatchRect(x, y, w, h, (el.properties.hatchSpacing as number) || 8);
|
||||
}
|
||||
this.ctx.strokeRect(x, y, w, h);
|
||||
}
|
||||
|
||||
private drawHatchRect(x: number, y: number, w: number, h: number, spacing: number): void {
|
||||
this.ctx.save();
|
||||
this.ctx.beginPath();
|
||||
this.ctx.rect(x, y, w, h);
|
||||
this.ctx.clip();
|
||||
this.ctx.strokeStyle = this.ctx.strokeStyle || '#888';
|
||||
this.ctx.lineWidth = 0.5;
|
||||
for (let i = -h; i < w + h; i += spacing) {
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(x + i, y);
|
||||
this.ctx.lineTo(x + i + h, y + h);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
private drawCircle(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
|
||||
const r = (el.properties.radius || el.width / 2) * s;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(sx(el.x), sy(el.y), Math.max(0.5, r), 0, Math.PI * 2);
|
||||
if (el.properties.fill) {
|
||||
this.ctx.fillStyle = el.properties.fill;
|
||||
this.ctx.fill();
|
||||
}
|
||||
if (el.properties.hatch) {
|
||||
this.ctx.save();
|
||||
this.ctx.clip();
|
||||
const cx = sx(el.x);
|
||||
const cy = sy(el.y);
|
||||
const spacing = (el.properties.hatchSpacing as number) || 8;
|
||||
this.ctx.lineWidth = 0.5;
|
||||
for (let i = -r; i < r * 2; i += spacing) {
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(cx - r + i, cy - r);
|
||||
this.ctx.lineTo(cx - r + i + r * 2, cy + r);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
this.ctx.restore();
|
||||
}
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private drawArc(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
|
||||
const r = (el.properties.radius || el.width / 2) * s;
|
||||
const start = (el.properties.startAngle || 0) * Math.PI / 180;
|
||||
const end = (el.properties.endAngle || 360) * Math.PI / 180;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(sx(el.x), sy(el.y), Math.max(0.5, r), start, end);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private drawPolyline(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number): void {
|
||||
const pts = el.properties.points || [];
|
||||
if (pts.length < 2) return;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(sx(pts[0].x), sy(pts[0].y));
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
this.ctx.lineTo(sx(pts[i].x), sy(pts[i].y));
|
||||
}
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private drawPolygon(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, fill?: string): void {
|
||||
const pts = el.properties.points || [];
|
||||
if (pts.length < 3) return;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(sx(pts[0].x), sy(pts[0].y));
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
this.ctx.lineTo(sx(pts[i].x), sy(pts[i].y));
|
||||
}
|
||||
this.ctx.closePath();
|
||||
if (fill || el.properties.fill) {
|
||||
this.ctx.fillStyle = fill || el.properties.fill!;
|
||||
this.ctx.fill();
|
||||
}
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private drawText(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
|
||||
const fontSize = (el.properties.fontSize || 12) * s;
|
||||
this.ctx.font = `${fontSize}px sans-serif`;
|
||||
this.ctx.fillStyle = el.properties.stroke || '#e0e0e0';
|
||||
this.ctx.textBaseline = 'top';
|
||||
const text = el.properties.text || '';
|
||||
const lines = String(text).split('\n');
|
||||
const align = (el.properties.align as string) || 'left';
|
||||
this.ctx.textAlign = align as CanvasTextAlign;
|
||||
if (el.properties.rotation) {
|
||||
this.ctx.save();
|
||||
this.ctx.translate(sx(el.x), sy(el.y));
|
||||
this.ctx.rotate((el.properties.rotation as number) * Math.PI / 180);
|
||||
lines.forEach((line, i) => {
|
||||
this.ctx.fillText(line, 0, i * fontSize * 1.2);
|
||||
});
|
||||
this.ctx.restore();
|
||||
} else {
|
||||
lines.forEach((line, i) => {
|
||||
this.ctx.fillText(line, sx(el.x), sy(el.y) + i * fontSize * 1.2);
|
||||
});
|
||||
}
|
||||
this.ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
private drawDimension(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
|
||||
const p = el.properties;
|
||||
const dimType = (p.dimType as string) || 'linear';
|
||||
const value = (p.value as string) || '';
|
||||
const arrowSize = 5 * s;
|
||||
|
||||
if (dimType === 'angular') {
|
||||
this.drawAngularDimension(el, sx, sy, s, arrowSize, value);
|
||||
return;
|
||||
}
|
||||
if (dimType === 'radial') {
|
||||
this.drawRadialDimension(el, sx, sy, s, arrowSize, value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Linear dimension
|
||||
const x1 = p.x1 ?? el.x - el.width / 2;
|
||||
const y1 = p.y1 ?? el.y;
|
||||
const x2 = p.x2 ?? el.x + el.width / 2;
|
||||
const y2 = p.y2 ?? el.y;
|
||||
const offset = 15 * s;
|
||||
|
||||
// Extension lines
|
||||
this.ctx.strokeStyle = '#888';
|
||||
this.ctx.lineWidth = 0.5 * s;
|
||||
this.ctx.setLineDash([]);
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(sx(x1), sy(y1));
|
||||
this.ctx.lineTo(sx(x1), sy(y1) - offset);
|
||||
this.ctx.moveTo(sx(x2), sy(y2));
|
||||
this.ctx.lineTo(sx(x2), sy(y2) - offset);
|
||||
this.ctx.stroke();
|
||||
|
||||
// Dimension line
|
||||
this.ctx.strokeStyle = '#aaa';
|
||||
this.ctx.lineWidth = 1 * s;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(sx(x1), sy(y1) - offset);
|
||||
this.ctx.lineTo(sx(x2), sy(y2) - offset);
|
||||
this.ctx.stroke();
|
||||
|
||||
// Arrows
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(sx(x1), sy(y1) - offset);
|
||||
this.ctx.lineTo(sx(x1) + arrowSize, sy(y1) - offset - arrowSize / 2);
|
||||
this.ctx.moveTo(sx(x1), sy(y1) - offset);
|
||||
this.ctx.lineTo(sx(x1) + arrowSize, sy(y1) - offset + arrowSize / 2);
|
||||
this.ctx.moveTo(sx(x2), sy(y2) - offset);
|
||||
this.ctx.lineTo(sx(x2) - arrowSize, sy(y2) - offset - arrowSize / 2);
|
||||
this.ctx.moveTo(sx(x2), sy(y2) - offset);
|
||||
this.ctx.lineTo(sx(x2) - arrowSize, sy(y2) - offset + arrowSize / 2);
|
||||
this.ctx.stroke();
|
||||
|
||||
// Text
|
||||
const midX = (x1 + x2) / 2;
|
||||
const midY = (y1 + y2) / 2;
|
||||
this.ctx.font = `${10 * s}px sans-serif`;
|
||||
this.ctx.fillStyle = '#ccc';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'bottom';
|
||||
this.ctx.fillText(value || Math.sqrt((x2-x1)**2+(y2-y1)**2).toFixed(1), sx(midX), sy(midY) - offset - 4);
|
||||
this.ctx.textAlign = 'left';
|
||||
this.ctx.textBaseline = 'top';
|
||||
}
|
||||
|
||||
private drawAngularDimension(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number, arrowSize: number, value: string): void {
|
||||
const p = el.properties;
|
||||
const vx = Number(p.x1 ?? el.x);
|
||||
const vy = Number(p.y1 ?? el.y);
|
||||
const ax1 = Number(p.ax1 ?? vx + 50);
|
||||
const ay1 = Number(p.ay1 ?? vy);
|
||||
const ax2 = Number(p.ax2 ?? vx + 50);
|
||||
const ay2 = Number(p.ay2 ?? vy + 50);
|
||||
const r = Number(p.radius) || 30;
|
||||
|
||||
const a1 = Math.atan2(ay1 - vy, ax1 - vx);
|
||||
const a2 = Math.atan2(ay2 - vy, ax2 - vx);
|
||||
|
||||
// Arc
|
||||
this.ctx.strokeStyle = '#aaa';
|
||||
this.ctx.lineWidth = 1 * s;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(sx(vx), sy(vy), r * s, a1, a2);
|
||||
this.ctx.stroke();
|
||||
|
||||
// Extension lines
|
||||
this.ctx.strokeStyle = '#888';
|
||||
this.ctx.lineWidth = 0.5 * s;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(sx(vx), sy(vy));
|
||||
this.ctx.lineTo(sx(ax1), sy(ay1));
|
||||
this.ctx.moveTo(sx(vx), sy(vy));
|
||||
this.ctx.lineTo(sx(ax2), sy(ay2));
|
||||
this.ctx.stroke();
|
||||
|
||||
// Text
|
||||
const midAngle = (a1 + a2) / 2;
|
||||
const tx = vx + Math.cos(midAngle) * (r + 10);
|
||||
const ty = vy + Math.sin(midAngle) * (r + 10);
|
||||
this.ctx.font = `${10 * s}px sans-serif`;
|
||||
this.ctx.fillStyle = '#ccc';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
this.ctx.fillText(value, sx(tx), sy(ty));
|
||||
this.ctx.textAlign = 'left';
|
||||
this.ctx.textBaseline = 'top';
|
||||
}
|
||||
|
||||
private drawRadialDimension(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number, arrowSize: number, value: string): void {
|
||||
const p = el.properties;
|
||||
const cx = p.x1 ?? el.x;
|
||||
const cy = p.y1 ?? el.y;
|
||||
const ex = p.x2 ?? el.x + el.width;
|
||||
const ey = p.y2 ?? el.y;
|
||||
|
||||
// Radial line
|
||||
this.ctx.strokeStyle = '#aaa';
|
||||
this.ctx.lineWidth = 1 * s;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(sx(cx), sy(cy));
|
||||
this.ctx.lineTo(sx(ex), sy(ey));
|
||||
this.ctx.stroke();
|
||||
|
||||
// Arrow at end
|
||||
const angle = Math.atan2(ey - cy, ex - cx);
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(sx(ex), sy(ey));
|
||||
this.ctx.lineTo(sx(ex) - arrowSize * Math.cos(angle - 0.3), sy(ey) - arrowSize * Math.sin(angle - 0.3));
|
||||
this.ctx.moveTo(sx(ex), sy(ey));
|
||||
this.ctx.lineTo(sx(ex) - arrowSize * Math.cos(angle + 0.3), sy(ey) - arrowSize * Math.sin(angle + 0.3));
|
||||
this.ctx.stroke();
|
||||
|
||||
// Text
|
||||
const midX = (cx + ex) / 2;
|
||||
const midY = (cy + ey) / 2;
|
||||
this.ctx.font = `${10 * s}px sans-serif`;
|
||||
this.ctx.fillStyle = '#ccc';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'bottom';
|
||||
this.ctx.fillText(value, sx(midX), sy(midY) - 4);
|
||||
this.ctx.textAlign = 'left';
|
||||
this.ctx.textBaseline = 'top';
|
||||
}
|
||||
|
||||
private drawLeader(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
|
||||
const p = el.properties;
|
||||
const x1 = p.x1 ?? el.x;
|
||||
const y1 = p.y1 ?? el.y;
|
||||
const x2 = p.x2 ?? el.x;
|
||||
const y2 = p.y2 ?? el.y;
|
||||
const text = (p.text as string) || '';
|
||||
const fontSize = ((p.fontSize as number) || 12) * s;
|
||||
|
||||
// Arrow at start point
|
||||
const angle = Math.atan2(y2 - y1, x2 - x1);
|
||||
const arrowSize = 6 * s;
|
||||
this.ctx.strokeStyle = p.stroke || '#e0e0e0';
|
||||
this.ctx.lineWidth = (p.strokeWidth as number) || 1;
|
||||
this.ctx.setLineDash([]);
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(sx(x1), sy(y1));
|
||||
this.ctx.lineTo(sx(x1) + arrowSize * Math.cos(angle - 0.4), sy(y1) + arrowSize * Math.sin(angle - 0.4));
|
||||
this.ctx.moveTo(sx(x1), sy(y1));
|
||||
this.ctx.lineTo(sx(x1) + arrowSize * Math.cos(angle + 0.4), sy(y1) + arrowSize * Math.sin(angle + 0.4));
|
||||
this.ctx.stroke();
|
||||
|
||||
// Leader line
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(sx(x1), sy(y1));
|
||||
this.ctx.lineTo(sx(x2), sy(y2));
|
||||
// Small horizontal dogleg
|
||||
const doglegX = (x2 > x1 ? 1 : -1) * 20;
|
||||
this.ctx.lineTo(sx(x2 + doglegX), sy(y2));
|
||||
this.ctx.stroke();
|
||||
|
||||
// Text
|
||||
if (text) {
|
||||
this.ctx.font = `${fontSize}px sans-serif`;
|
||||
this.ctx.fillStyle = p.stroke || '#e0e0e0';
|
||||
this.ctx.textBaseline = 'bottom';
|
||||
this.ctx.textAlign = x2 > x1 ? 'left' : 'right';
|
||||
this.ctx.fillText(text, sx(x2 + doglegX), sy(y2) - 2);
|
||||
this.ctx.textAlign = 'left';
|
||||
this.ctx.textBaseline = 'top';
|
||||
}
|
||||
}
|
||||
|
||||
private drawRevCloud(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
|
||||
const p = el.properties;
|
||||
const points = (p.points as Array<{x:number;y:number}>) || [];
|
||||
if (points.length < 2) return;
|
||||
const arcHeight = ((p.arcHeight as number) || 8) * s;
|
||||
|
||||
this.ctx.strokeStyle = p.stroke || '#e0e0e0';
|
||||
this.ctx.lineWidth = (p.strokeWidth as number) || 1.5;
|
||||
this.ctx.setLineDash([]);
|
||||
if (p.fill && p.fill !== 'none') {
|
||||
this.ctx.fillStyle = p.fill;
|
||||
}
|
||||
|
||||
this.ctx.beginPath();
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const cur = points[i];
|
||||
const next = points[(i + 1) % points.length];
|
||||
const mx = (cur.x + next.x) / 2;
|
||||
const my = (cur.y + next.y) / 2;
|
||||
const dist = Math.sqrt((next.x - cur.x) ** 2 + (next.y - cur.y) ** 2);
|
||||
const bulge = Math.min(arcHeight / dist, 0.5);
|
||||
// Draw arc segment as quadratic curve with bulge
|
||||
const cpX = mx + (next.y - cur.y) * bulge;
|
||||
const cpY = my - (next.x - cur.x) * bulge;
|
||||
if (i === 0) this.ctx.moveTo(sx(cur.x), sy(cur.y));
|
||||
this.ctx.quadraticCurveTo(sx(cpX), sy(cpY), sx(next.x), sy(next.y));
|
||||
}
|
||||
this.ctx.closePath();
|
||||
if (p.fill && p.fill !== 'none') this.ctx.fill();
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private drawBlockInstance(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
|
||||
const blockId = el.properties.blockId as string;
|
||||
const blockDef = this.blockDefinitions.get(blockId);
|
||||
if (!blockDef) {
|
||||
// Fallback: bounding box
|
||||
const w = el.width * s;
|
||||
const h = el.height * s;
|
||||
this.ctx.strokeStyle = '#666';
|
||||
this.ctx.setLineDash([4, 4]);
|
||||
this.ctx.strokeRect(sx(el.x) - w / 2, sy(el.y) - h / 2, w, h);
|
||||
this.ctx.setLineDash([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const rotation = (el.properties.rotation || 0) * Math.PI / 180;
|
||||
const scale = (el.properties.scale || 1) * s;
|
||||
const ox = el.properties.offsetX || 0;
|
||||
const oy = el.properties.offsetY || 0;
|
||||
const cx = sx(el.x);
|
||||
const cy = sy(el.y);
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.translate(cx, cy);
|
||||
this.ctx.rotate(rotation);
|
||||
|
||||
for (const childEl of blockDef.elements) {
|
||||
const lx = (childEl.x + Number(ox)) * scale;
|
||||
const ly = (childEl.y + Number(oy)) * scale;
|
||||
const lw = childEl.width * scale;
|
||||
const lh = childEl.height * scale;
|
||||
const props = { ...childEl.properties };
|
||||
|
||||
this.ctx.save();
|
||||
if (props.fill) this.ctx.fillStyle = props.fill;
|
||||
this.ctx.strokeStyle = props.stroke || '#999';
|
||||
this.ctx.lineWidth = Math.max(0.5, 1 * s);
|
||||
|
||||
switch (childEl.type) {
|
||||
case 'rect':
|
||||
if (props.fill) this.ctx.fillRect(lx - lw / 2, ly - lh / 2, lw, lh);
|
||||
this.ctx.strokeRect(lx - lw / 2, ly - lh / 2, lw, lh);
|
||||
break;
|
||||
case 'circle': {
|
||||
const r = (props.radius || lw / 2) * scale / s;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(lx, ly, r, 0, Math.PI * 2);
|
||||
if (props.fill) this.ctx.fill();
|
||||
this.ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'line': {
|
||||
const x1 = (Number(props.x1) + Number(ox)) * scale;
|
||||
const y1 = (Number(props.y1) + Number(oy)) * scale;
|
||||
const x2 = (Number(props.x2) + Number(ox)) * scale;
|
||||
const y2 = (Number(props.y2) + Number(oy)) * scale;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(x1, y1);
|
||||
this.ctx.lineTo(x2, y2);
|
||||
this.ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
const r = (props.radius || lw / 2) * scale / s;
|
||||
const startAngle = (props.startAngle || 0) * Math.PI / 180;
|
||||
const endAngle = (props.endAngle || 360) * Math.PI / 180;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(lx, ly, r, startAngle, endAngle);
|
||||
this.ctx.stroke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
private drawChair(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
|
||||
const w = el.width * s;
|
||||
const h = el.height * s;
|
||||
const cx = sx(el.x);
|
||||
const cy = sy(el.y);
|
||||
const rot = (el.properties.rotation || 0) * Math.PI / 180;
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.translate(cx, cy);
|
||||
this.ctx.rotate(rot);
|
||||
|
||||
// Seat
|
||||
if (el.properties.fill) {
|
||||
this.ctx.fillStyle = el.properties.fill;
|
||||
} else {
|
||||
this.ctx.fillStyle = '#4a90d9';
|
||||
}
|
||||
this.ctx.fillRect(-w / 2, -h / 2, w, h);
|
||||
|
||||
// Backrest (top portion)
|
||||
this.ctx.fillStyle = '#3a7ac9';
|
||||
this.ctx.fillRect(-w / 2, -h / 2, w, h * 0.2);
|
||||
|
||||
// Outline
|
||||
this.ctx.strokeStyle = '#2a5a99';
|
||||
this.ctx.lineWidth = 0.5;
|
||||
this.ctx.strokeRect(-w / 2, -h / 2, w, h);
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
private drawTable(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
|
||||
const w = el.width * s;
|
||||
const h = el.height * s;
|
||||
const cx = sx(el.x);
|
||||
const cy = sy(el.y);
|
||||
const rot = (el.properties.rotation || 0) * Math.PI / 180;
|
||||
const shape = el.properties.shape || 'rect';
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.translate(cx, cy);
|
||||
this.ctx.rotate(rot);
|
||||
|
||||
if (shape === 'round') {
|
||||
const r = Math.min(w, h) / 2;
|
||||
this.ctx.fillStyle = el.properties.fill || '#8b6f47';
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(0, 0, r, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
this.ctx.strokeStyle = el.properties.stroke || '#5a4a37';
|
||||
this.ctx.lineWidth = (el.properties.strokeWidth || 1.5) * s;
|
||||
this.ctx.stroke();
|
||||
} else {
|
||||
this.ctx.fillStyle = el.properties.fill || '#8b6f47';
|
||||
this.ctx.fillRect(-w / 2, -h / 2, w, h);
|
||||
this.ctx.strokeStyle = el.properties.stroke || '#5a4a37';
|
||||
this.ctx.lineWidth = (el.properties.strokeWidth || 1.5) * s;
|
||||
this.ctx.strokeRect(-w / 2, -h / 2, w, h);
|
||||
}
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
private drawStage(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
|
||||
const w = el.width * s;
|
||||
const h = el.height * s;
|
||||
const cx = sx(el.x);
|
||||
const cy = sy(el.y);
|
||||
const rot = (el.properties.rotation || 0) * Math.PI / 180;
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.translate(cx, cy);
|
||||
this.ctx.rotate(rot);
|
||||
|
||||
// Stage floor
|
||||
this.ctx.fillStyle = el.properties.fill || '#2c3e50';
|
||||
this.ctx.fillRect(-w / 2, -h / 2, w, h);
|
||||
|
||||
// Border
|
||||
this.ctx.strokeStyle = el.properties.stroke || '#1a2e3f';
|
||||
this.ctx.lineWidth = (el.properties.strokeWidth || 2) * s;
|
||||
this.ctx.strokeRect(-w / 2, -h / 2, w, h);
|
||||
|
||||
// Label
|
||||
const label = el.properties.label as string || 'Bühne';
|
||||
this.ctx.fillStyle = '#fff';
|
||||
this.ctx.font = `${Math.max(10, 14 * s)}px Inter, sans-serif`;
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
this.ctx.fillText(label, 0, 0);
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
private drawSelectionHandles(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
|
||||
const w = el.width * s;
|
||||
const h = el.height * s;
|
||||
const x = sx(el.x) - w / 2;
|
||||
const y = sy(el.y) - h / 2;
|
||||
const handleSize = 6;
|
||||
this.ctx.fillStyle = '#00aaff';
|
||||
this.ctx.strokeStyle = '#fff';
|
||||
this.ctx.lineWidth = 1;
|
||||
const corners = [
|
||||
[x, y], [x + w, y], [x, y + h], [x + w, y + h],
|
||||
[x + w / 2, y], [x + w / 2, y + h], [x, y + h / 2], [x + w, y + h / 2],
|
||||
];
|
||||
for (const [hx, hy] of corners) {
|
||||
this.ctx.fillRect(hx - handleSize / 2, hy - handleSize / 2, handleSize, handleSize);
|
||||
this.ctx.strokeRect(hx - handleSize / 2, hy - handleSize / 2, handleSize, handleSize);
|
||||
}
|
||||
}
|
||||
|
||||
private drawSelectionBox(): void {
|
||||
if (!this.selection.boxStart || !this.selection.boxEnd) return;
|
||||
const s = this.zoomPan.getScale();
|
||||
const ox = this.zoomPan.getTransform().e;
|
||||
const oy = this.zoomPan.getTransform().f;
|
||||
const x1 = this.selection.boxStart.x * s + ox;
|
||||
const y1 = this.selection.boxStart.y * s + oy;
|
||||
const x2 = this.selection.boxEnd.x * s + ox;
|
||||
const y2 = this.selection.boxEnd.y * s + oy;
|
||||
this.ctx.strokeStyle = '#00aaff';
|
||||
this.ctx.fillStyle = 'rgba(0, 170, 255, 0.1)';
|
||||
this.ctx.lineWidth = 1;
|
||||
this.ctx.setLineDash([4, 4]);
|
||||
this.ctx.fillRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1));
|
||||
this.ctx.strokeRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1));
|
||||
this.ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
private drawSnapPoints(): void {
|
||||
const s = this.zoomPan.getScale();
|
||||
const ox = this.zoomPan.getTransform().e;
|
||||
const oy = this.zoomPan.getTransform().f;
|
||||
for (const pt of this.snapPoints) {
|
||||
const x = pt.x * s + ox;
|
||||
const y = pt.y * s + oy;
|
||||
const isActive = this.activeSnapPoint?.x === pt.x && this.activeSnapPoint?.y === pt.y;
|
||||
this.ctx.fillStyle = isActive ? '#ff0' : '#0f0';
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(x, y, isActive ? 6 : 4, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
private drawOrtho(): void {
|
||||
// Draw ortho tracking line from cursor
|
||||
// Placeholder — will be connected to interaction engine
|
||||
}
|
||||
|
||||
// Hit testing
|
||||
hitTest(worldX: number, worldY: number, tolerance: number = 5): CADElement | null {
|
||||
const viewport = this.zoomPan.getViewport();
|
||||
const candidates = this.spatialIndex.search({
|
||||
minX: worldX - tolerance, minY: worldY - tolerance,
|
||||
maxX: worldX + tolerance, maxY: worldY + tolerance,
|
||||
});
|
||||
const visibleLayerIds = new Set(
|
||||
this.layerManager.getVisibleLayers().map(l => l.id),
|
||||
);
|
||||
let best: CADElement | null = null;
|
||||
let bestDist = tolerance;
|
||||
for (const el of candidates) {
|
||||
if (!visibleLayerIds.has(el.layerId)) continue;
|
||||
const dist = this.elementDistance(el, worldX, worldY);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = el;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private elementDistance(el: CADElement, x: number, y: number): number {
|
||||
const p = el.properties;
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = p.x1 ?? el.x;
|
||||
const y1 = p.y1 ?? el.y;
|
||||
const x2 = p.x2 ?? el.x + el.width;
|
||||
const y2 = p.y2 ?? el.y + el.height;
|
||||
return this.pointToSegmentDist(x, y, x1, y1, x2, y2);
|
||||
}
|
||||
case 'circle': {
|
||||
const r = p.radius || el.width / 2;
|
||||
const d = Math.sqrt((x - el.x) ** 2 + (y - el.y) ** 2);
|
||||
return Math.abs(d - r);
|
||||
}
|
||||
case 'rect':
|
||||
case 'table':
|
||||
case 'stage': {
|
||||
const halfW = el.width / 2;
|
||||
const halfH = el.height / 2;
|
||||
const dx = Math.max(Math.abs(x - el.x) - halfW, 0);
|
||||
const dy = Math.max(Math.abs(y - el.y) - halfH, 0);
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
case 'polyline':
|
||||
case 'polygon': {
|
||||
const pts = p.points || [];
|
||||
let minDist = Infinity;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const d = this.pointToSegmentDist(x, y, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
|
||||
minDist = Math.min(minDist, d);
|
||||
}
|
||||
if (el.type === 'polygon' && pts.length > 2) {
|
||||
const d = this.pointToSegmentDist(x, y, pts[pts.length - 1].x, pts[pts.length - 1].y, pts[0].x, pts[0].y);
|
||||
minDist = Math.min(minDist, d);
|
||||
}
|
||||
return minDist;
|
||||
}
|
||||
default: {
|
||||
const halfW = el.width / 2;
|
||||
const halfH = el.height / 2;
|
||||
const dx = Math.max(Math.abs(x - el.x) - halfW, 0);
|
||||
const dy = Math.max(Math.abs(y - el.y) - halfH, 0);
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private pointToSegmentDist(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq === 0) return Math.sqrt((px - x1) ** 2 + (py - y1) ** 2);
|
||||
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const cx = x1 + t * dx;
|
||||
const cy = y1 + t * dy;
|
||||
return Math.sqrt((px - cx) ** 2 + (py - cy) ** 2);
|
||||
}
|
||||
|
||||
// Bounding box for an element
|
||||
getElementBBox(el: CADElement): BoundingBox {
|
||||
const halfW = el.width / 2;
|
||||
const halfH = el.height / 2;
|
||||
return {
|
||||
minX: el.x - halfW, minY: el.y - halfH,
|
||||
maxX: el.x + halfW, maxY: el.y + halfH,
|
||||
};
|
||||
}
|
||||
|
||||
// Get elements within a world-space rectangle (for box selection)
|
||||
getElementsInRect(minX: number, minY: number, maxX: number, maxY: number): CADElement[] {
|
||||
const candidates = this.spatialIndex.search({ minX, minY, maxX, maxY });
|
||||
const visibleLayerIds = new Set(
|
||||
this.layerManager.getVisibleLayers().map(l => l.id),
|
||||
);
|
||||
return candidates.filter(el => {
|
||||
if (!visibleLayerIds.has(el.layerId)) return false;
|
||||
const bb = this.getElementBBox(el);
|
||||
return bb.minX >= minX && bb.maxX <= maxX && bb.minY >= minY && bb.maxY <= maxY;
|
||||
});
|
||||
}
|
||||
|
||||
// Get elements intersecting a world-space rectangle (for crossing selection)
|
||||
getElementsIntersectingRect(minX: number, minY: number, maxX: number, maxY: number): CADElement[] {
|
||||
const candidates = this.spatialIndex.search({ minX, minY, maxX, maxY });
|
||||
const visibleLayerIds = new Set(
|
||||
this.layerManager.getVisibleLayers().map(l => l.id),
|
||||
);
|
||||
return candidates.filter(el => {
|
||||
if (!visibleLayerIds.has(el.layerId)) return false;
|
||||
const bb = this.getElementBBox(el);
|
||||
// Check if bbox intersects the rect (not necessarily fully enclosed)
|
||||
return bb.minX <= maxX && bb.maxX >= minX && bb.minY <= maxY && bb.maxY >= minY;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import type { CADElement, CADLayer } from '../types/cad.types';
|
||||
import { RenderEngine } from './RenderEngine';
|
||||
import { SpatialIndex } from './SpatialIndex';
|
||||
import { LayerManager } from './LayerManager';
|
||||
|
||||
export type SelectionMode = 'single' | 'multiple' | 'box' | 'lasso';
|
||||
export type SelectionFilter = 'all' | 'lines' | 'circles' | 'rects' | 'text' | 'chairs' | 'blocks';
|
||||
|
||||
export interface SelectionOptions {
|
||||
mode: SelectionMode;
|
||||
filter: SelectionFilter;
|
||||
additive: boolean; // shift-click to add to selection
|
||||
subtractive: boolean; // ctrl-click to remove from selection
|
||||
tolerance: number; // world units for hit testing
|
||||
}
|
||||
|
||||
export class SelectionEngine {
|
||||
private renderEngine: RenderEngine;
|
||||
private spatialIndex: SpatialIndex;
|
||||
private layerManager: LayerManager;
|
||||
private selectedIds: Set<string> = new Set();
|
||||
private hoverId: string | null = null;
|
||||
private options: SelectionOptions;
|
||||
private boxStart: { x: number; y: number } | null = null;
|
||||
private boxEnd: { x: number; y: number } | null = null;
|
||||
private listeners: Array<(selected: CADElement[]) => void> = [];
|
||||
|
||||
constructor(
|
||||
renderEngine: RenderEngine,
|
||||
spatialIndex: SpatialIndex,
|
||||
layerManager: LayerManager,
|
||||
) {
|
||||
this.renderEngine = renderEngine;
|
||||
this.spatialIndex = spatialIndex;
|
||||
this.layerManager = layerManager;
|
||||
this.options = {
|
||||
mode: 'single',
|
||||
filter: 'all',
|
||||
additive: false,
|
||||
subtractive: false,
|
||||
tolerance: 5,
|
||||
};
|
||||
}
|
||||
|
||||
setOptions(opts: Partial<SelectionOptions>): void {
|
||||
this.options = { ...this.options, ...opts };
|
||||
}
|
||||
|
||||
getOptions(): SelectionOptions {
|
||||
return { ...this.options };
|
||||
}
|
||||
|
||||
getSelectedIds(): Set<string> {
|
||||
return new Set(this.selectedIds);
|
||||
}
|
||||
|
||||
getSelectedElements(allElements: CADElement[]): CADElement[] {
|
||||
return allElements.filter(e => this.selectedIds.has(e.id));
|
||||
}
|
||||
|
||||
getHoverId(): string | null {
|
||||
return this.hoverId;
|
||||
}
|
||||
|
||||
setHover(id: string | null): void {
|
||||
this.hoverId = id;
|
||||
this.updateRenderSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click selection at world coordinates.
|
||||
* Returns the selected element or null.
|
||||
*/
|
||||
clickSelect(worldX: number, worldY: number, allElements: CADElement[]): CADElement | null {
|
||||
const hit = this.renderEngine.hitTest(worldX, worldY, this.options.tolerance);
|
||||
if (!hit) {
|
||||
if (!this.options.additive && !this.options.subtractive) {
|
||||
this.clearSelection();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.matchesFilter(hit)) {
|
||||
if (!this.options.additive && !this.options.subtractive) {
|
||||
this.clearSelection();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.options.subtractive) {
|
||||
this.selectedIds.delete(hit.id);
|
||||
} else if (this.options.additive) {
|
||||
this.selectedIds.add(hit.id);
|
||||
} else {
|
||||
this.clearSelection();
|
||||
this.selectedIds.add(hit.id);
|
||||
}
|
||||
|
||||
this.updateRenderSelection();
|
||||
this.notifyListeners(allElements);
|
||||
return hit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start box selection at world coordinates.
|
||||
*/
|
||||
startBoxSelect(worldX: number, worldY: number): void {
|
||||
this.boxStart = { x: worldX, y: worldY };
|
||||
this.boxEnd = { x: worldX, y: worldY };
|
||||
this.updateRenderSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update box selection end point during drag.
|
||||
*/
|
||||
updateBoxSelect(worldX: number, worldY: number, allElements: CADElement[]): void {
|
||||
if (!this.boxStart) return;
|
||||
this.boxEnd = { x: worldX, y: worldY };
|
||||
this.updateRenderSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish box selection and select all elements within the box.
|
||||
* Left-to-right drag = window selection (fully enclosed elements).
|
||||
* Right-to-left drag = crossing selection (intersecting elements).
|
||||
*/
|
||||
finishBoxSelect(allElements: CADElement[]): CADElement[] {
|
||||
if (!this.boxStart || !this.boxEnd) {
|
||||
this.boxStart = null;
|
||||
this.boxEnd = null;
|
||||
return [];
|
||||
}
|
||||
|
||||
const minX = Math.min(this.boxStart.x, this.boxEnd.x);
|
||||
const minY = Math.min(this.boxStart.y, this.boxEnd.y);
|
||||
const maxX = Math.max(this.boxStart.x, this.boxEnd.x);
|
||||
const maxY = Math.max(this.boxStart.y, this.boxEnd.y);
|
||||
|
||||
// Determine window vs crossing: if start X < end X, it's window (left-to-right)
|
||||
const isWindow = this.boxStart.x <= this.boxEnd.x;
|
||||
|
||||
let elements: CADElement[];
|
||||
if (isWindow) {
|
||||
// Window selection: only fully enclosed elements
|
||||
elements = this.renderEngine.getElementsInRect(minX, minY, maxX, maxY);
|
||||
} else {
|
||||
// Crossing selection: all intersecting elements
|
||||
elements = this.renderEngine.getElementsIntersectingRect(minX, minY, maxX, maxY);
|
||||
}
|
||||
const filtered = elements.filter(e => this.matchesFilter(e));
|
||||
|
||||
if (this.options.subtractive) {
|
||||
for (const el of filtered) this.selectedIds.delete(el.id);
|
||||
} else if (this.options.additive) {
|
||||
for (const el of filtered) this.selectedIds.add(el.id);
|
||||
} else {
|
||||
this.clearSelection();
|
||||
for (const el of filtered) this.selectedIds.add(el.id);
|
||||
}
|
||||
|
||||
this.boxStart = null;
|
||||
this.boxEnd = null;
|
||||
this.updateRenderSelection();
|
||||
this.notifyListeners(allElements);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick select elements by property value.
|
||||
* Supports filtering by type, layerId, color (stroke/fill), or any property.
|
||||
*/
|
||||
quickSelect(allElements: CADElement[], criteria: {
|
||||
type?: string;
|
||||
layerId?: string;
|
||||
color?: string;
|
||||
property?: string;
|
||||
value?: unknown;
|
||||
}, additive: boolean = false): CADElement[] {
|
||||
if (!additive) this.clearSelection();
|
||||
|
||||
const matched = allElements.filter(el => {
|
||||
if (criteria.type && el.type !== criteria.type) return false;
|
||||
if (criteria.layerId && el.layerId !== criteria.layerId) return false;
|
||||
if (criteria.color) {
|
||||
const elColor = el.properties.stroke ?? el.properties.fill;
|
||||
if (elColor !== criteria.color) return false;
|
||||
}
|
||||
if (criteria.property && criteria.value !== undefined) {
|
||||
if ((el.properties as Record<string, unknown>)[criteria.property] !== criteria.value) return false;
|
||||
}
|
||||
return this.matchesFilter(el);
|
||||
});
|
||||
|
||||
for (const el of matched) this.selectedIds.add(el.id);
|
||||
this.updateRenderSelection();
|
||||
this.notifyListeners(allElements);
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any in-progress box selection.
|
||||
*/
|
||||
cancelBoxSelect(): void {
|
||||
this.boxStart = null;
|
||||
this.boxEnd = null;
|
||||
this.updateRenderSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all elements matching the current filter.
|
||||
*/
|
||||
selectAll(allElements: CADElement[]): void {
|
||||
const visibleLayerIds = new Set(this.layerManager.getVisibleLayers().map(l => l.id));
|
||||
this.selectedIds.clear();
|
||||
for (const el of allElements) {
|
||||
if (!visibleLayerIds.has(el.layerId)) continue;
|
||||
if (this.matchesFilter(el)) this.selectedIds.add(el.id);
|
||||
}
|
||||
this.updateRenderSelection();
|
||||
this.notifyListeners(allElements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invert current selection.
|
||||
*/
|
||||
invertSelection(allElements: CADElement[]): void {
|
||||
const visibleLayerIds = new Set(this.layerManager.getVisibleLayers().map(l => l.id));
|
||||
const newSelection = new Set<string>();
|
||||
for (const el of allElements) {
|
||||
if (!visibleLayerIds.has(el.layerId)) continue;
|
||||
if (!this.matchesFilter(el)) continue;
|
||||
if (!this.selectedIds.has(el.id)) newSelection.add(el.id);
|
||||
}
|
||||
this.selectedIds = newSelection;
|
||||
this.updateRenderSelection();
|
||||
this.notifyListeners(allElements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selection.
|
||||
*/
|
||||
clearSelection(): void {
|
||||
this.selectedIds.clear();
|
||||
this.updateRenderSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select specific elements by ID.
|
||||
*/
|
||||
selectByIds(ids: string[], additive: boolean = false): void {
|
||||
if (!additive) this.selectedIds.clear();
|
||||
for (const id of ids) this.selectedIds.add(id);
|
||||
this.updateRenderSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a listener that gets called when selection changes.
|
||||
*/
|
||||
addListener(fn: (selected: CADElement[]) => void): void {
|
||||
this.listeners.push(fn);
|
||||
}
|
||||
|
||||
removeListener(fn: (selected: CADElement[]) => void): void {
|
||||
this.listeners = this.listeners.filter(f => f !== fn);
|
||||
}
|
||||
|
||||
private matchesFilter(el: CADElement): boolean {
|
||||
switch (this.options.filter) {
|
||||
case 'all': return true;
|
||||
case 'lines': return el.type === 'line';
|
||||
case 'circles': return el.type === 'circle' || el.type === 'arc';
|
||||
case 'rects': return el.type === 'rect';
|
||||
case 'text': return el.type === 'text';
|
||||
case 'chairs': return el.type === 'chair';
|
||||
case 'blocks': return el.type === 'block_instance';
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
private updateRenderSelection(): void {
|
||||
this.renderEngine.setSelection({
|
||||
selectedIds: this.selectedIds,
|
||||
hoverId: this.hoverId,
|
||||
boxStart: this.boxStart,
|
||||
boxEnd: this.boxEnd,
|
||||
});
|
||||
}
|
||||
|
||||
private notifyListeners(allElements: CADElement[]): void {
|
||||
const selected = allElements.filter(e => this.selectedIds.has(e.id));
|
||||
for (const fn of this.listeners) fn(selected);
|
||||
}
|
||||
|
||||
isBoxSelecting(): boolean {
|
||||
return this.boxStart !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import type { CADElement } from '../types/cad.types';
|
||||
import type { SnapPoint } from './RenderEngine';
|
||||
|
||||
export type SnapMode =
|
||||
| 'endpoint' | 'midpoint' | 'center' | 'intersection'
|
||||
| 'nearest' | 'perpendicular' | 'tangent' | 'quadrant'
|
||||
| 'grid' | 'none';
|
||||
|
||||
export interface SnapConfig {
|
||||
enabled: boolean;
|
||||
modes: Set<SnapMode>;
|
||||
tolerance: number; // world units
|
||||
gridSpacing: number;
|
||||
polarEnabled: boolean;
|
||||
polarAngles: number[]; // angles in degrees for polar tracking
|
||||
polarTolerance: number; // angular tolerance in degrees
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
point: SnapPoint | null;
|
||||
preview: SnapPoint[]; // nearby candidates for visual feedback
|
||||
}
|
||||
|
||||
export class SnapEngine {
|
||||
private config: SnapConfig;
|
||||
private elements: CADElement[] = [];
|
||||
|
||||
constructor(config?: Partial<SnapConfig>) {
|
||||
this.config = {
|
||||
enabled: true,
|
||||
modes: new Set<SnapMode>(['endpoint', 'midpoint', 'center', 'intersection', 'nearest']),
|
||||
tolerance: 10,
|
||||
gridSpacing: 20,
|
||||
polarEnabled: false,
|
||||
polarAngles: [0, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330],
|
||||
polarTolerance: 5,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
setElements(elements: CADElement[]): void {
|
||||
this.elements = elements;
|
||||
}
|
||||
|
||||
setConfig(config: Partial<SnapConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
getConfig(): SnapConfig {
|
||||
return { ...this.config, modes: new Set(this.config.modes) };
|
||||
}
|
||||
|
||||
toggleMode(mode: SnapMode): void {
|
||||
if (this.config.modes.has(mode)) {
|
||||
this.config.modes.delete(mode);
|
||||
} else {
|
||||
this.config.modes.add(mode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best snap point near the given world coordinates.
|
||||
* Returns null if no snap point is within tolerance.
|
||||
* If refPoint is provided and polar tracking is enabled, snaps to polar angles.
|
||||
*/
|
||||
snap(worldX: number, worldY: number, refPoint?: { x: number; y: number }): SnapResult {
|
||||
if (!this.config.enabled || this.config.modes.size === 0) {
|
||||
return { point: null, preview: [] };
|
||||
}
|
||||
|
||||
// Polar tracking: if we have a reference point, check polar angles first
|
||||
if (this.config.polarEnabled && refPoint) {
|
||||
const polarResult = this.polarSnap(worldX, worldY, refPoint);
|
||||
if (polarResult) {
|
||||
return { point: polarResult, preview: [polarResult] };
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: SnapPoint[] = [];
|
||||
const tol = this.config.tolerance;
|
||||
|
||||
// Grid snap (lowest priority)
|
||||
if (this.config.modes.has('grid')) {
|
||||
const gs = this.config.gridSpacing;
|
||||
const gx = Math.round(worldX / gs) * gs;
|
||||
const gy = Math.round(worldY / gs) * gs;
|
||||
const dist = Math.sqrt((worldX - gx) ** 2 + (worldY - gy) ** 2);
|
||||
if (dist < tol) {
|
||||
candidates.push({ x: gx, y: gy, type: 'grid' as SnapMode as any });
|
||||
}
|
||||
}
|
||||
|
||||
// Element-based snaps
|
||||
for (const el of this.elements) {
|
||||
if (this.config.modes.has('endpoint')) {
|
||||
this.collectEndpoints(el, worldX, worldY, tol, candidates);
|
||||
}
|
||||
if (this.config.modes.has('midpoint')) {
|
||||
this.collectMidpoints(el, worldX, worldY, tol, candidates);
|
||||
}
|
||||
if (this.config.modes.has('center')) {
|
||||
this.collectCenters(el, worldX, worldY, tol, candidates);
|
||||
}
|
||||
if (this.config.modes.has('nearest')) {
|
||||
this.collectNearest(el, worldX, worldY, tol, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
// Intersection snap (between pairs)
|
||||
if (this.config.modes.has('intersection')) {
|
||||
this.collectIntersections(worldX, worldY, tol, candidates);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { point: null, preview: [] };
|
||||
}
|
||||
|
||||
// Sort by distance, pick closest
|
||||
candidates.sort((a, b) => {
|
||||
const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2;
|
||||
const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2;
|
||||
return da - db;
|
||||
});
|
||||
|
||||
// Priority: endpoint > intersection > center > midpoint > nearest > grid
|
||||
const priority: Record<string, number> = {
|
||||
endpoint: 0, intersection: 1, center: 2, midpoint: 3, nearest: 4, grid: 5,
|
||||
};
|
||||
|
||||
// Find best within tolerance — prefer higher priority if distances are close
|
||||
const best = candidates[0];
|
||||
const closeOnes = candidates.filter(c => {
|
||||
const d = Math.sqrt((c.x - worldX) ** 2 + (c.y - worldY) ** 2);
|
||||
return d < tol * 1.5;
|
||||
});
|
||||
closeOnes.sort((a, b) => {
|
||||
const pa = priority[a.type] ?? 99;
|
||||
const pb = priority[b.type] ?? 99;
|
||||
if (pa !== pb) return pa - pb;
|
||||
const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2;
|
||||
const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2;
|
||||
return da - db;
|
||||
});
|
||||
|
||||
return {
|
||||
point: closeOnes[0] || best,
|
||||
preview: candidates.slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
private collectEndpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
||||
const p = el.properties;
|
||||
const check = (x: number, y: number) => {
|
||||
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
|
||||
if (d < tol) out.push({ x, y, type: 'endpoint' });
|
||||
};
|
||||
|
||||
switch (el.type) {
|
||||
case 'line':
|
||||
check(p.x1 ?? el.x, p.y1 ?? el.y);
|
||||
check(p.x2 ?? el.x + el.width, p.y2 ?? el.y + el.height);
|
||||
break;
|
||||
case 'polyline':
|
||||
case 'polygon': {
|
||||
const pts = p.points || [];
|
||||
for (const pt of pts) check(pt.x, pt.y);
|
||||
break;
|
||||
}
|
||||
case 'rect':
|
||||
check(el.x - el.width / 2, el.y - el.height / 2);
|
||||
check(el.x + el.width / 2, el.y - el.height / 2);
|
||||
check(el.x - el.width / 2, el.y + el.height / 2);
|
||||
check(el.x + el.width / 2, el.y + el.height / 2);
|
||||
break;
|
||||
case 'arc': {
|
||||
const r = p.radius || el.width / 2;
|
||||
const sa = (p.startAngle || 0) * Math.PI / 180;
|
||||
const ea = (p.endAngle || 360) * Math.PI / 180;
|
||||
check(el.x + r * Math.cos(sa), el.y + r * Math.sin(sa));
|
||||
check(el.x + r * Math.cos(ea), el.y + r * Math.sin(ea));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private collectMidpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
||||
const p = el.properties;
|
||||
const check = (x: number, y: number) => {
|
||||
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
|
||||
if (d < tol) out.push({ x, y, type: 'midpoint' });
|
||||
};
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y;
|
||||
const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height;
|
||||
check((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
break;
|
||||
}
|
||||
case 'polyline':
|
||||
case 'polygon': {
|
||||
const pts = p.points || [];
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
check((pts[i].x + pts[i + 1].x) / 2, (pts[i].y + pts[i + 1].y) / 2);
|
||||
}
|
||||
if (el.type === 'polygon' && pts.length > 2) {
|
||||
check((pts[pts.length - 1].x + pts[0].x) / 2, (pts[pts.length - 1].y + pts[0].y) / 2);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rect':
|
||||
check(el.x, el.y - el.height / 2);
|
||||
check(el.x, el.y + el.height / 2);
|
||||
check(el.x - el.width / 2, el.y);
|
||||
check(el.x + el.width / 2, el.y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private collectCenters(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
||||
const check = (x: number, y: number) => {
|
||||
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
|
||||
if (d < tol) out.push({ x, y, type: 'center' });
|
||||
};
|
||||
|
||||
switch (el.type) {
|
||||
case 'circle':
|
||||
case 'arc':
|
||||
check(el.x, el.y);
|
||||
break;
|
||||
case 'rect':
|
||||
check(el.x, el.y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private collectNearest(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
||||
const p = el.properties;
|
||||
const check = (x: number, y: number) => {
|
||||
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
|
||||
if (d < tol) out.push({ x, y, type: 'nearest' });
|
||||
};
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y;
|
||||
const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height;
|
||||
const np = this.nearestOnSegment(wx, wy, x1, y1, x2, y2);
|
||||
check(np.x, np.y);
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const r = p.radius || el.width / 2;
|
||||
const d = Math.sqrt((wx - el.x) ** 2 + (wy - el.y) ** 2);
|
||||
if (d > 0) {
|
||||
check(el.x + r * (wx - el.x) / d, el.y + r * (wy - el.y) / d);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polyline':
|
||||
case 'polygon': {
|
||||
const pts = p.points || [];
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const np = this.nearestOnSegment(wx, wy, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
|
||||
check(np.x, np.y);
|
||||
}
|
||||
if (el.type === 'polygon' && pts.length > 2) {
|
||||
const np = this.nearestOnSegment(wx, wy, pts[pts.length - 1].x, pts[pts.length - 1].y, pts[0].x, pts[0].y);
|
||||
check(np.x, np.y);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private collectIntersections(wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
||||
// Check pairs of elements near the cursor
|
||||
const nearby = this.elements.filter(el => {
|
||||
const halfW = el.width / 2 + tol;
|
||||
const halfH = el.height / 2 + tol;
|
||||
return Math.abs(wx - el.x) < halfW && Math.abs(wy - el.y) < halfH;
|
||||
});
|
||||
|
||||
for (let i = 0; i < nearby.length; i++) {
|
||||
for (let j = i + 1; j < nearby.length; j++) {
|
||||
const pts = this.findIntersection(nearby[i], nearby[j]);
|
||||
for (const pt of pts) {
|
||||
const d = Math.sqrt((wx - pt.x) ** 2 + (wy - pt.y) ** 2);
|
||||
if (d < tol) out.push({ x: pt.x, y: pt.y, type: 'intersection' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findIntersection(a: CADElement, b: CADElement): Array<{ x: number; y: number }> {
|
||||
// Get line segments from both elements
|
||||
const segsA = this.getElementSegments(a);
|
||||
const segsB = this.getElementSegments(b);
|
||||
const results: Array<{ x: number; y: number }> = [];
|
||||
|
||||
for (const sa of segsA) {
|
||||
for (const sb of segsB) {
|
||||
const pt = this.segmentIntersection(sa.x1, sa.y1, sa.x2, sa.y2, sb.x1, sb.y1, sb.x2, sb.y2);
|
||||
if (pt) results.push(pt);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private getElementSegments(el: CADElement): Array<{ x1: number; y1: number; x2: number; y2: number }> {
|
||||
const p = el.properties;
|
||||
switch (el.type) {
|
||||
case 'line':
|
||||
return [{
|
||||
x1: p.x1 ?? el.x, y1: p.y1 ?? el.y,
|
||||
x2: p.x2 ?? el.x + el.width, y2: p.y2 ?? el.y + el.height,
|
||||
}];
|
||||
case 'rect': {
|
||||
const hw = el.width / 2, hh = el.height / 2;
|
||||
return [
|
||||
{ x1: el.x - hw, y1: el.y - hh, x2: el.x + hw, y2: el.y - hh },
|
||||
{ x1: el.x + hw, y1: el.y - hh, x2: el.x + hw, y2: el.y + hh },
|
||||
{ x1: el.x + hw, y1: el.y + hh, x2: el.x - hw, y2: el.y + hh },
|
||||
{ x1: el.x - hw, y1: el.y + hh, x2: el.x - hw, y2: el.y - hh },
|
||||
];
|
||||
}
|
||||
case 'polyline':
|
||||
case 'polygon': {
|
||||
const pts = p.points || [];
|
||||
const segs: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
segs.push({ x1: pts[i].x, y1: pts[i].y, x2: pts[i + 1].x, y2: pts[i + 1].y });
|
||||
}
|
||||
if (el.type === 'polygon' && pts.length > 2) {
|
||||
segs.push({ x1: pts[pts.length - 1].x, y1: pts[pts.length - 1].y, x2: pts[0].x, y2: pts[0].y });
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private segmentIntersection(
|
||||
x1: number, y1: number, x2: number, y2: number,
|
||||
x3: number, y3: number, x4: number, y4: number,
|
||||
): { x: number; y: number } | null {
|
||||
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
|
||||
if (Math.abs(denom) < 1e-10) return null;
|
||||
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
|
||||
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
|
||||
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
|
||||
return { x: x1 + t * (x2 - x1), y: y1 + t * (y2 - y1) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private nearestOnSegment(px: number, py: number, x1: number, y1: number, x2: number, y2: number): { x: number; y: number } {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq === 0) return { x: x1, y: y1 };
|
||||
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return { x: x1 + t * dx, y: y1 + t * dy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Polar tracking: snap cursor to the nearest polar angle from a reference point.
|
||||
* Returns a SnapPoint if the cursor is close to a polar angle, or null.
|
||||
*/
|
||||
private polarSnap(worldX: number, worldY: number, refPoint: { x: number; y: number }): SnapPoint | null {
|
||||
const dx = worldX - refPoint.x;
|
||||
const dy = worldY - refPoint.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 1) return null; // too close to reference point
|
||||
|
||||
const cursorAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
const normalizedCursor = ((cursorAngle % 360) + 360) % 360;
|
||||
|
||||
// Find closest polar angle
|
||||
let bestAngle: number | null = null;
|
||||
let bestDiff = Infinity;
|
||||
for (const angle of this.config.polarAngles) {
|
||||
let diff = Math.abs(normalizedCursor - angle);
|
||||
if (diff > 180) diff = 360 - diff;
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
bestAngle = angle;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestAngle === null || bestDiff > this.config.polarTolerance) return null;
|
||||
|
||||
// Project cursor position onto the polar angle line at the same distance
|
||||
const rad = (bestAngle * Math.PI) / 180;
|
||||
const snapX = refPoint.x + dist * Math.cos(rad);
|
||||
const snapY = refPoint.y + dist * Math.sin(rad);
|
||||
|
||||
return { x: snapX, y: snapY, type: 'nearest' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import RBush from 'rbush';
|
||||
import type { BoundingBox, CADElement } from '../types/cad.types';
|
||||
|
||||
interface IndexedItem extends BoundingBox {
|
||||
element: CADElement;
|
||||
}
|
||||
|
||||
export class SpatialIndex {
|
||||
private tree: RBush<IndexedItem>;
|
||||
|
||||
constructor() {
|
||||
this.tree = new RBush<IndexedItem>();
|
||||
}
|
||||
|
||||
insert(element: CADElement): void {
|
||||
const bbox = this.elementBBox(element);
|
||||
this.tree.insert({ ...bbox, element });
|
||||
}
|
||||
|
||||
bulkInsert(elements: CADElement[]): void {
|
||||
const items = elements.map(el => ({ ...this.elementBBox(el), element: el }));
|
||||
this.tree.load(items);
|
||||
}
|
||||
|
||||
search(viewport: BoundingBox): CADElement[] {
|
||||
return this.tree.search(viewport).map(item => item.element);
|
||||
}
|
||||
|
||||
remove(element: CADElement): void {
|
||||
const bbox = this.elementBBox(element);
|
||||
this.tree.remove({ ...bbox, element }, (a, b) => a.element.id === b.element.id);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tree.clear();
|
||||
}
|
||||
|
||||
private elementBBox(el: CADElement): BoundingBox {
|
||||
const halfW = el.width / 2;
|
||||
const halfH = el.height / 2;
|
||||
return {
|
||||
minX: el.x - halfW,
|
||||
minY: el.y - halfH,
|
||||
maxX: el.x + halfW,
|
||||
maxY: el.y + halfH,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Transform, Viewport } from '../types/cad.types';
|
||||
|
||||
export class ZoomPanController {
|
||||
private scale = 1;
|
||||
private offsetX = 0;
|
||||
private offsetY = 0;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private isPanning = false;
|
||||
private lastX = 0;
|
||||
private lastY = 0;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas;
|
||||
}
|
||||
|
||||
getTransform(): Transform {
|
||||
return {
|
||||
a: this.scale, b: 0, c: 0,
|
||||
d: this.scale, e: this.offsetX, f: this.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
getViewport(): Viewport {
|
||||
const w = this.canvas.width / this.scale;
|
||||
const h = this.canvas.height / this.scale;
|
||||
return {
|
||||
minX: -this.offsetX / this.scale,
|
||||
minY: -this.offsetY / this.scale,
|
||||
maxX: (-this.offsetX / this.scale) + w,
|
||||
maxY: (-this.offsetY / this.scale) + h,
|
||||
};
|
||||
}
|
||||
|
||||
getScale(): number { return this.scale; }
|
||||
|
||||
zoomAt(cx: number, cy: number, factor: number): void {
|
||||
const newScale = Math.max(0.01, Math.min(100, this.scale * factor));
|
||||
const ratio = newScale / this.scale;
|
||||
this.offsetX = cx - (cx - this.offsetX) * ratio;
|
||||
this.offsetY = cy - (cy - this.offsetY) * ratio;
|
||||
this.scale = newScale;
|
||||
}
|
||||
|
||||
zoomFit(elements: Array<{x:number;y:number;width:number;height:number}>): void {
|
||||
if (elements.length === 0) return;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of elements) {
|
||||
minX = Math.min(minX, el.x - el.width/2);
|
||||
minY = Math.min(minY, el.y - el.height/2);
|
||||
maxX = Math.max(maxX, el.x + el.width/2);
|
||||
maxY = Math.max(maxY, el.y + el.height/2);
|
||||
}
|
||||
const padding = 40;
|
||||
const scaleX = (this.canvas.width - padding*2) / (maxX - minX);
|
||||
const scaleY = (this.canvas.height - padding*2) / (maxY - minY);
|
||||
this.scale = Math.min(scaleX, scaleY);
|
||||
this.offsetX = -minX * this.scale + padding;
|
||||
this.offsetY = -minY * this.scale + padding;
|
||||
}
|
||||
|
||||
zoomToRect(rect: { minX: number; minY: number; maxX: number; maxY: number }): void {
|
||||
const padding = 20;
|
||||
const w = rect.maxX - rect.minX;
|
||||
const h = rect.maxY - rect.minY;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
const scaleX = (this.canvas.width - padding * 2) / w;
|
||||
const scaleY = (this.canvas.height - padding * 2) / h;
|
||||
this.scale = Math.max(0.01, Math.min(100, Math.min(scaleX, scaleY)));
|
||||
this.offsetX = -rect.minX * this.scale + padding;
|
||||
this.offsetY = -rect.minY * this.scale + padding;
|
||||
}
|
||||
|
||||
pan(dx: number, dy: number): void {
|
||||
this.offsetX += dx;
|
||||
this.offsetY += dy;
|
||||
}
|
||||
|
||||
screenToWorld(sx: number, sy: number): { x: number; y: number } {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const x = (sx - rect.left - this.offsetX) / this.scale;
|
||||
const y = (sy - rect.top - this.offsetY) / this.scale;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
worldToScreen(wx: number, wy: number): { x: number; y: number } {
|
||||
return {
|
||||
x: wx * this.scale + this.offsetX,
|
||||
y: wy * this.scale + this.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.scale = 1;
|
||||
this.offsetX = 0;
|
||||
this.offsetY = 0;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* AuthContext – Frontend authentication state management
|
||||
*/
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||
|
||||
const API_BASE = 'http://localhost:3001';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, name: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem('auth_token'));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Validate token on mount
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
fetch(`${API_BASE}/api/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
if (data) setUser(data);
|
||||
else {
|
||||
localStorage.removeItem('auth_token');
|
||||
setToken(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('auth_token');
|
||||
setToken(null);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
||||
setUser(data.user);
|
||||
setToken(data.session.token);
|
||||
localStorage.setItem('auth_token', data.session.token);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (email: string, password: string, name: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Registration failed');
|
||||
setUser(data.user);
|
||||
setToken(data.session.token);
|
||||
localStorage.setItem('auth_token', data.session.token);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
if (token) {
|
||||
fetch(`${API_BASE}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).catch(() => {});
|
||||
}
|
||||
localStorage.removeItem('auth_token');
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
}, [token]);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, error, login, register, logout, clearError }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* AwarenessManager – Tracks user presence, cursor position, and selection.
|
||||
* Uses a Y.Map inside the shared doc so awareness state syncs via the same
|
||||
* raw-update WebSocket protocol as the rest of the document.
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import type { YjsDocument } from './YjsDocument';
|
||||
|
||||
export interface UserCursor {
|
||||
userId: string;
|
||||
userName: string;
|
||||
color: string;
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export interface UserSelection {
|
||||
userId: string;
|
||||
elementIds: string[];
|
||||
}
|
||||
|
||||
export interface AwarenessState {
|
||||
cursors: Y.Map<UserCursor>;
|
||||
selections: Y.Map<UserSelection>;
|
||||
}
|
||||
|
||||
export class AwarenessManager {
|
||||
readonly state: AwarenessState;
|
||||
private yjsDoc: YjsDocument;
|
||||
private userId: string;
|
||||
private listeners: Set<() => void> = new Set();
|
||||
|
||||
constructor(yjsDoc: YjsDocument, userId: string) {
|
||||
this.yjsDoc = yjsDoc;
|
||||
this.userId = userId;
|
||||
this.state = {
|
||||
cursors: this.yjsDoc.doc.getMap<UserCursor>('awareness-cursors'),
|
||||
selections: this.yjsDoc.doc.getMap<UserSelection>('awareness-selections'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Set the local user's cursor position */
|
||||
setCursor(x: number, y: number, userName: string, color: string): void {
|
||||
this.state.cursors.set(this.userId, {
|
||||
userId: this.userId,
|
||||
userName,
|
||||
color,
|
||||
x,
|
||||
y,
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Hide the local user's cursor */
|
||||
hideCursor(): void {
|
||||
const cur = this.state.cursors.get(this.userId);
|
||||
if (cur) {
|
||||
this.state.cursors.set(this.userId, { ...cur, visible: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the local user's selection */
|
||||
setSelection(elementIds: string[]): void {
|
||||
this.state.selections.set(this.userId, {
|
||||
userId: this.userId,
|
||||
elementIds,
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the local user's selection */
|
||||
clearSelection(): void {
|
||||
this.state.selections.delete(this.userId);
|
||||
}
|
||||
|
||||
/** Remove the local user from awareness (on disconnect) */
|
||||
removeSelf(): void {
|
||||
this.state.cursors.delete(this.userId);
|
||||
this.state.selections.delete(this.userId);
|
||||
}
|
||||
|
||||
/** Get all active cursors */
|
||||
getAllCursors(): UserCursor[] {
|
||||
return Array.from(this.state.cursors.values()).filter((c) => c.visible);
|
||||
}
|
||||
|
||||
/** Get all selections */
|
||||
getAllSelections(): UserSelection[] {
|
||||
return Array.from(this.state.selections.values());
|
||||
}
|
||||
|
||||
/** Get a specific user's cursor */
|
||||
getCursor(userId: string): UserCursor | undefined {
|
||||
return this.state.cursors.get(userId);
|
||||
}
|
||||
|
||||
/** Get a specific user's selection */
|
||||
getSelection(userId: string): UserSelection | undefined {
|
||||
return this.state.selections.get(userId);
|
||||
}
|
||||
|
||||
/** Register a callback for awareness changes */
|
||||
onChange(callback: () => void): () => void {
|
||||
this.listeners.add(callback);
|
||||
const cursorObserver = () => this.notifyListeners();
|
||||
const selectionObserver = () => this.notifyListeners();
|
||||
this.state.cursors.observe(cursorObserver);
|
||||
this.state.selections.observe(selectionObserver);
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
this.state.cursors.unobserve(cursorObserver);
|
||||
this.state.selections.unobserve(selectionObserver);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
for (const cb of this.listeners) {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* WebSocketProvider – Custom WebSocket provider matching the backend raw-update protocol.
|
||||
* Backend sends Y.encodeStateAsUpdate on connect and applies raw updates on message.
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import type { YjsDocument } from './YjsDocument';
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export interface WebSocketProviderOptions {
|
||||
url: string;
|
||||
docName: string;
|
||||
yjsDoc: YjsDocument;
|
||||
onStatusChange?: (status: ConnectionStatus) => void;
|
||||
onSync?: () => void;
|
||||
}
|
||||
|
||||
export class WebSocketProvider {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private docName: string;
|
||||
private yjsDoc: YjsDocument;
|
||||
private status: ConnectionStatus = 'disconnected';
|
||||
private onStatusChange?: (status: ConnectionStatus) => void;
|
||||
private onSync?: () => void;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectDelay = 1000;
|
||||
private maxReconnectDelay = 30000;
|
||||
private shouldReconnect = true;
|
||||
|
||||
constructor(opts: WebSocketProviderOptions) {
|
||||
this.url = opts.url;
|
||||
this.docName = opts.docName;
|
||||
this.yjsDoc = opts.yjsDoc;
|
||||
this.onStatusChange = opts.onStatusChange;
|
||||
this.onSync = opts.onSync;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
this.setStatus('connecting');
|
||||
|
||||
const fullUrl = `${this.url}/ws/collab/${encodeURIComponent(this.docName)}`;
|
||||
this.ws = new WebSocket(fullUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.setStatus('connected');
|
||||
this.reconnectDelay = 1000;
|
||||
// Send local state to server for initial sync
|
||||
const stateUpdate = Y.encodeStateAsUpdate(this.yjsDoc.doc);
|
||||
if (stateUpdate.length > 0) {
|
||||
this.ws?.send(stateUpdate);
|
||||
}
|
||||
this.onSync?.();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = new Uint8Array(event.data as ArrayBuffer);
|
||||
this.yjsDoc.applyUpdate(data);
|
||||
} catch (err) {
|
||||
console.warn('[WebSocketProvider] Failed to apply update:', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.setStatus('error');
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.ws = null;
|
||||
this.setStatus('disconnected');
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a local Y.Doc update to the server */
|
||||
sendUpdate(update: Uint8Array): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(update);
|
||||
}
|
||||
}
|
||||
|
||||
/** Listen for local doc changes and forward to server */
|
||||
bindLocalUpdates(): () => void {
|
||||
const handler = (update: Uint8Array, origin: unknown) => {
|
||||
// Only send updates that originated locally (not from remote apply)
|
||||
if (origin !== 'remote') {
|
||||
this.sendUpdate(update);
|
||||
}
|
||||
};
|
||||
this.yjsDoc.doc.on('update', handler);
|
||||
return () => {
|
||||
this.yjsDoc.doc.off('update', handler);
|
||||
};
|
||||
}
|
||||
|
||||
getStatus(): ConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.setStatus('disconnected');
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
||||
}
|
||||
|
||||
private setStatus(status: ConnectionStatus): void {
|
||||
if (this.status !== status) {
|
||||
this.status = status;
|
||||
this.onStatusChange?.(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* YjsDocument – Manages a Y.Doc with shared maps for CAD elements, layers, and blocks.
|
||||
* Provides helpers to sync local state with the CRDT document.
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
|
||||
|
||||
export interface YjsDocumentData {
|
||||
elements: Y.Map<CADElement>;
|
||||
layers: Y.Map<CADLayer>;
|
||||
blocks: Y.Map<BlockDefinition>;
|
||||
meta: Y.Map<unknown>;
|
||||
}
|
||||
|
||||
export class YjsDocument {
|
||||
readonly doc: Y.Doc;
|
||||
readonly data: YjsDocumentData;
|
||||
|
||||
constructor() {
|
||||
this.doc = new Y.Doc();
|
||||
this.data = {
|
||||
elements: this.doc.getMap<CADElement>('elements'),
|
||||
layers: this.doc.getMap<CADLayer>('layers'),
|
||||
blocks: this.doc.getMap<BlockDefinition>('blocks'),
|
||||
meta: this.doc.getMap<unknown>('meta'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Get all elements as a plain array */
|
||||
getElements(): CADElement[] {
|
||||
return Array.from(this.data.elements.values());
|
||||
}
|
||||
|
||||
/** Get all layers as a plain array */
|
||||
getLayers(): CADLayer[] {
|
||||
return Array.from(this.data.layers.values());
|
||||
}
|
||||
|
||||
/** Get all blocks as a plain array */
|
||||
getBlocks(): BlockDefinition[] {
|
||||
return Array.from(this.data.blocks.values());
|
||||
}
|
||||
|
||||
/** Add or update a single element */
|
||||
setElement(el: CADElement): void {
|
||||
this.doc.transact(() => {
|
||||
this.data.elements.set(el.id, el);
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove an element by id */
|
||||
deleteElement(id: string): void {
|
||||
this.data.elements.delete(id);
|
||||
}
|
||||
|
||||
/** Add or update a layer */
|
||||
setLayer(layer: CADLayer): void {
|
||||
this.data.layers.set(layer.id, layer);
|
||||
}
|
||||
|
||||
/** Remove a layer by id */
|
||||
deleteLayer(id: string): void {
|
||||
this.data.layers.delete(id);
|
||||
}
|
||||
|
||||
/** Add or update a block definition */
|
||||
setBlock(block: BlockDefinition): void {
|
||||
this.data.blocks.set(block.id, block);
|
||||
}
|
||||
|
||||
/** Remove a block by id */
|
||||
deleteBlock(id: string): void {
|
||||
this.data.blocks.delete(id);
|
||||
}
|
||||
|
||||
/** Bulk-load local state into the Y.Doc (replaces all content) */
|
||||
loadFromState(state: {
|
||||
elements: CADElement[];
|
||||
layers: CADLayer[];
|
||||
blocks: BlockDefinition[];
|
||||
}): void {
|
||||
this.doc.transact(() => {
|
||||
this.data.elements.clear();
|
||||
for (const el of state.elements) {
|
||||
this.data.elements.set(el.id, el);
|
||||
}
|
||||
this.data.layers.clear();
|
||||
for (const layer of state.layers) {
|
||||
this.data.layers.set(layer.id, layer);
|
||||
}
|
||||
this.data.blocks.clear();
|
||||
for (const block of state.blocks) {
|
||||
this.data.blocks.set(block.id, block);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Export current state as a plain object */
|
||||
toState(): {
|
||||
elements: CADElement[];
|
||||
layers: CADLayer[];
|
||||
blocks: BlockDefinition[];
|
||||
} {
|
||||
return {
|
||||
elements: this.getElements(),
|
||||
layers: this.getLayers(),
|
||||
blocks: this.getBlocks(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Encode full document state as update binary */
|
||||
encodeState(): Uint8Array {
|
||||
return Y.encodeStateAsUpdate(this.doc);
|
||||
}
|
||||
|
||||
/** Apply a remote update binary */
|
||||
applyUpdate(update: Uint8Array): void {
|
||||
Y.applyUpdate(this.doc, update);
|
||||
}
|
||||
|
||||
/** Register a callback for document changes */
|
||||
onChange(callback: () => void): () => void {
|
||||
this.doc.on('update', callback);
|
||||
return () => this.doc.off('update', callback);
|
||||
}
|
||||
|
||||
/** Destroy the document */
|
||||
destroy(): void {
|
||||
this.doc.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* CRDT module – Real-time collaboration via Yjs
|
||||
*/
|
||||
export { YjsDocument, type YjsDocumentData } from './YjsDocument';
|
||||
export { WebSocketProvider, type ConnectionStatus, type WebSocketProviderOptions } from './WebSocketProvider';
|
||||
export { AwarenessManager, type UserCursor, type UserSelection, type AwarenessState } from './AwarenessManager';
|
||||
export { useYjsBinding, type UseYjsBindingOptions, type UseYjsBindingResult } from './useYjsBinding';
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* useYjsBinding – React hook that ties YjsDocument, WebSocketProvider,
|
||||
* and AwarenessManager together for real-time collaboration.
|
||||
*/
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
import { YjsDocument } from './YjsDocument';
|
||||
import { WebSocketProvider, type ConnectionStatus } from './WebSocketProvider';
|
||||
import { AwarenessManager, type UserCursor, type UserSelection } from './AwarenessManager';
|
||||
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
|
||||
|
||||
export interface UseYjsBindingOptions {
|
||||
docName: string;
|
||||
wsUrl?: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userColor: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseYjsBindingResult {
|
||||
yjsDoc: YjsDocument | null;
|
||||
provider: WebSocketProvider | null;
|
||||
awareness: AwarenessManager | null;
|
||||
status: ConnectionStatus;
|
||||
elements: CADElement[];
|
||||
layers: CADLayer[];
|
||||
blocks: BlockDefinition[];
|
||||
cursors: UserCursor[];
|
||||
selections: UserSelection[];
|
||||
setElement: (el: CADElement) => void;
|
||||
deleteElement: (id: string) => void;
|
||||
setLayer: (layer: CADLayer) => void;
|
||||
deleteLayer: (id: string) => void;
|
||||
setBlock: (block: BlockDefinition) => void;
|
||||
deleteBlock: (id: string) => void;
|
||||
loadFromState: (state: { elements: CADElement[]; layers: CADLayer[]; blocks: BlockDefinition[] }) => void;
|
||||
setCursor: (x: number, y: number) => void;
|
||||
hideCursor: () => void;
|
||||
setSelection: (elementIds: string[]) => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_WS_URL = `ws://${window.location.hostname}:3001`;
|
||||
|
||||
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||
const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true } = opts;
|
||||
|
||||
const yjsDocRef = useRef<YjsDocument | null>(null);
|
||||
const providerRef = useRef<WebSocketProvider | null>(null);
|
||||
const awarenessRef = useRef<AwarenessManager | null>(null);
|
||||
const unbindRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [elements, setElements] = useState<CADElement[]>([]);
|
||||
const [layers, setLayers] = useState<CADLayer[]>([]);
|
||||
const [blocks, setBlocks] = useState<BlockDefinition[]>([]);
|
||||
const [cursors, setCursors] = useState<UserCursor[]>([]);
|
||||
const [selections, setSelections] = useState<UserSelection[]>([]);
|
||||
|
||||
// Initialize Yjs document, provider, and awareness
|
||||
useEffect(() => {
|
||||
if (!enabled || !docName) return;
|
||||
|
||||
const doc = new YjsDocument();
|
||||
yjsDocRef.current = doc;
|
||||
|
||||
const provider = new WebSocketProvider({
|
||||
url: wsUrl,
|
||||
docName,
|
||||
yjsDoc: doc,
|
||||
onStatusChange: (s) => setStatus(s),
|
||||
});
|
||||
providerRef.current = provider;
|
||||
|
||||
const awareness = new AwarenessManager(doc, userId);
|
||||
awarenessRef.current = awareness;
|
||||
|
||||
// Sync local state from Y.Doc on changes
|
||||
const syncState = () => {
|
||||
setElements(doc.getElements());
|
||||
setLayers(doc.getLayers());
|
||||
setBlocks(doc.getBlocks());
|
||||
setCursors(awareness.getAllCursors());
|
||||
setSelections(awareness.getAllSelections());
|
||||
};
|
||||
|
||||
const unbindDoc = doc.onChange(syncState);
|
||||
const unbindAwareness = awareness.onChange(syncState);
|
||||
|
||||
// Forward local updates to server
|
||||
const unbindLocal = provider.bindLocalUpdates();
|
||||
|
||||
// Connect
|
||||
provider.connect();
|
||||
syncState();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
unbindDoc();
|
||||
unbindAwareness();
|
||||
unbindLocal();
|
||||
awareness.removeSelf();
|
||||
provider.disconnect();
|
||||
doc.destroy();
|
||||
yjsDocRef.current = null;
|
||||
providerRef.current = null;
|
||||
awarenessRef.current = null;
|
||||
};
|
||||
}, [docName, wsUrl, userId, userName, userColor, enabled]);
|
||||
|
||||
// Mutators
|
||||
const setElement = useCallback((el: CADElement) => {
|
||||
yjsDocRef.current?.setElement(el);
|
||||
}, []);
|
||||
|
||||
const deleteElement = useCallback((id: string) => {
|
||||
yjsDocRef.current?.deleteElement(id);
|
||||
}, []);
|
||||
|
||||
const setLayer = useCallback((layer: CADLayer) => {
|
||||
yjsDocRef.current?.setLayer(layer);
|
||||
}, []);
|
||||
|
||||
const deleteLayer = useCallback((id: string) => {
|
||||
yjsDocRef.current?.deleteLayer(id);
|
||||
}, []);
|
||||
|
||||
const setBlock = useCallback((block: BlockDefinition) => {
|
||||
yjsDocRef.current?.setBlock(block);
|
||||
}, []);
|
||||
|
||||
const deleteBlock = useCallback((id: string) => {
|
||||
yjsDocRef.current?.deleteBlock(id);
|
||||
}, []);
|
||||
|
||||
const loadFromState = useCallback((state: { elements: CADElement[]; layers: CADLayer[]; blocks: BlockDefinition[] }) => {
|
||||
yjsDocRef.current?.loadFromState(state);
|
||||
}, []);
|
||||
|
||||
const setCursor = useCallback((x: number, y: number) => {
|
||||
awarenessRef.current?.setCursor(x, y, userName, userColor);
|
||||
}, [userName, userColor]);
|
||||
|
||||
const hideCursor = useCallback(() => {
|
||||
awarenessRef.current?.hideCursor();
|
||||
}, []);
|
||||
|
||||
const setSelection = useCallback((elementIds: string[]) => {
|
||||
awarenessRef.current?.setSelection(elementIds);
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
awarenessRef.current?.clearSelection();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
yjsDoc: yjsDocRef.current,
|
||||
provider: providerRef.current,
|
||||
awareness: awarenessRef.current,
|
||||
status,
|
||||
elements,
|
||||
layers,
|
||||
blocks,
|
||||
cursors,
|
||||
selections,
|
||||
setElement,
|
||||
deleteElement,
|
||||
setLayer,
|
||||
deleteLayer,
|
||||
setBlock,
|
||||
deleteBlock,
|
||||
loadFromState,
|
||||
setCursor,
|
||||
hideCursor,
|
||||
setSelection,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* HistoryManager – Undo/Redo Stack mit beliebig vielen Schritten.
|
||||
* Speichert Snapshots des kompletten CAD-Zustands (elements, layers, blocks, groups, bgConfig).
|
||||
* F-CAD-09: Undo/Redo mit History
|
||||
*/
|
||||
|
||||
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
|
||||
import type { ElementGroup } from '../tools/modification/GroupTool';
|
||||
import type { BackgroundConfig } from '../services/backgroundService';
|
||||
|
||||
/** Vollständiger CAD-Zustand für einen History-Snapshot */
|
||||
export interface CADStateSnapshot {
|
||||
elements: CADElement[];
|
||||
layers: CADLayer[];
|
||||
blocks: BlockDefinition[];
|
||||
groups: ElementGroup[];
|
||||
bgConfig: BackgroundConfig | null;
|
||||
timestamp: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** History-Eintrag für die Historie-Anzeige */
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface HistoryManagerOptions {
|
||||
maxStackSize?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_SIZE = 100;
|
||||
|
||||
export class HistoryManager {
|
||||
private undoStack: CADStateSnapshot[] = [];
|
||||
private redoStack: CADStateSnapshot[] = [];
|
||||
private currentState: CADStateSnapshot | null = null;
|
||||
private maxStackSize: number;
|
||||
private listeners: Set<() => void> = new Set();
|
||||
private idCounter = 0;
|
||||
|
||||
constructor(options: HistoryManagerOptions = {}) {
|
||||
this.maxStackSize = options.maxStackSize ?? DEFAULT_MAX_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialen Zustand setzen (ohne Undo-Eintrag zu erzeugen).
|
||||
* Wird beim Laden eines Projekts aufgerufen.
|
||||
*/
|
||||
initialize(snapshot: Omit<CADStateSnapshot, 'timestamp' | 'label'>): void {
|
||||
this.currentState = {
|
||||
...snapshot,
|
||||
timestamp: Date.now(),
|
||||
label: 'Initial',
|
||||
};
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine neue Operation aufzeichnen.
|
||||
* Der aktuelle Zustand wird auf den Undo-Stack geschoben,
|
||||
* der neue Zustand wird zum aktuellen.
|
||||
* Der Redo-Stack wird geleert.
|
||||
*/
|
||||
pushSnapshot(snapshot: Omit<CADStateSnapshot, 'timestamp' | 'label'>, label: string): void {
|
||||
if (this.currentState) {
|
||||
this.undoStack.push(this.currentState);
|
||||
if (this.undoStack.length > this.maxStackSize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
}
|
||||
this.currentState = {
|
||||
...snapshot,
|
||||
timestamp: Date.now(),
|
||||
label,
|
||||
};
|
||||
this.redoStack = [];
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo: aktuellen Zustand auf Redo-Stack, letzten Undo-Eintrag holen.
|
||||
* Gibt den vorherigen Zustand zurück oder null, wenn kein Undo möglich.
|
||||
*/
|
||||
undo(): CADStateSnapshot | null {
|
||||
if (this.undoStack.length === 0 || !this.currentState) return null;
|
||||
this.redoStack.push(this.currentState);
|
||||
const previous = this.undoStack.pop()!;
|
||||
this.currentState = previous;
|
||||
this.notifyListeners();
|
||||
return previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo: aktuellen Zustand auf Undo-Stack, nächsten Redo-Eintrag holen.
|
||||
* Gibt den nächsten Zustand zurück oder null, wenn kein Redo möglich.
|
||||
*/
|
||||
redo(): CADStateSnapshot | null {
|
||||
if (this.redoStack.length === 0 || !this.currentState) return null;
|
||||
this.undoStack.push(this.currentState);
|
||||
const next = this.redoStack.pop()!;
|
||||
this.currentState = next;
|
||||
this.notifyListeners();
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Kann Undo ausgeführt werden? */
|
||||
canUndo(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
/** Kann Redo ausgeführt werden? */
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
/** Aktuellen Zustand zurückgeben */
|
||||
getCurrentState(): CADStateSnapshot | null {
|
||||
return this.currentState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Historie als Liste zurückgeben (für UI-Anzeige).
|
||||
* Neueste zuerst, mit isCurrent-Markierung.
|
||||
*/
|
||||
getHistory(): HistoryEntry[] {
|
||||
const entries: HistoryEntry[] = [];
|
||||
// Undo-Stack (älteste zuerst)
|
||||
for (let i = 0; i < this.undoStack.length; i++) {
|
||||
entries.push({
|
||||
id: `undo-${i}`,
|
||||
label: this.undoStack[i].label,
|
||||
timestamp: this.undoStack[i].timestamp,
|
||||
isCurrent: false,
|
||||
});
|
||||
}
|
||||
// Aktueller Zustand
|
||||
if (this.currentState) {
|
||||
entries.push({
|
||||
id: 'current',
|
||||
label: this.currentState.label,
|
||||
timestamp: this.currentState.timestamp,
|
||||
isCurrent: true,
|
||||
});
|
||||
}
|
||||
// Redo-Stack (neueste zuerst, also umgekehrt)
|
||||
for (let i = this.redoStack.length - 1; i >= 0; i--) {
|
||||
entries.push({
|
||||
id: `redo-${i}`,
|
||||
label: this.redoStack[i].label,
|
||||
timestamp: this.redoStack[i].timestamp,
|
||||
isCurrent: false,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Anzahl der Undo-Schritte */
|
||||
getUndoCount(): number {
|
||||
return this.undoStack.length;
|
||||
}
|
||||
|
||||
/** Anzahl der Redo-Schritte */
|
||||
getRedoCount(): number {
|
||||
return this.redoStack.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zu einem bestimmten History-Eintrag springen.
|
||||
* Macht mehrere Undo- oder Redo-Schritte auf einmal.
|
||||
*/
|
||||
jumpTo(entryId: string): CADStateSnapshot | null {
|
||||
if (entryId === 'current') return this.currentState;
|
||||
if (entryId.startsWith('undo-')) {
|
||||
const idx = parseInt(entryId.substring(5), 10);
|
||||
// Undo bis zu diesem Eintrag
|
||||
let result: CADStateSnapshot | null = null;
|
||||
for (let i = this.undoStack.length - 1; i >= idx; i--) {
|
||||
result = this.undo();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (entryId.startsWith('redo-')) {
|
||||
const idx = parseInt(entryId.substring(5), 10);
|
||||
let result: CADStateSnapshot | null = null;
|
||||
for (let i = 0; i <= idx; i++) {
|
||||
result = this.redo();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Alle History löschen und neu initialisieren */
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.currentState = null;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/** Listener registrieren (für React-Updates) */
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
this.listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** Eindeutige ID generieren */
|
||||
private generateId(): string {
|
||||
return `hist-${++this.idCounter}-${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Default HistoryManager-Instanz (Singleton) */
|
||||
let defaultManager: HistoryManager | null = null;
|
||||
|
||||
export function getDefaultHistoryManager(): HistoryManager {
|
||||
if (!defaultManager) {
|
||||
defaultManager = new HistoryManager();
|
||||
}
|
||||
return defaultManager;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { HistoryManager, getDefaultHistoryManager } from './HistoryManager';
|
||||
export type { CADStateSnapshot, HistoryEntry, HistoryManagerOptions } from './HistoryManager';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
throw new Error('Root container #root not found');
|
||||
}
|
||||
|
||||
createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Dashboard Page – Project overview after login
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const API_BASE = 'http://localhost:3001';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface DashboardProps {
|
||||
onOpenProject: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export function Dashboard({ onOpenProject }: DashboardProps) {
|
||||
const { user, logout, token } = useAuth();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/projects`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
setProjects(await res.json());
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, [fetchProjects]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name: newName, description: newDesc || null }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
setShowCreate(false);
|
||||
fetchProjects();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Projekt wirklich löschen?')) return;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/projects/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
fetchProjects();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<header className="dashboard-header">
|
||||
<div className="dashboard-brand">
|
||||
<h1>Web CAD</h1>
|
||||
<span className="dashboard-user">{user?.name} ({user?.role})</span>
|
||||
</div>
|
||||
<button className="dashboard-logout" onClick={logout}>Abmelden</button>
|
||||
</header>
|
||||
|
||||
<div className="dashboard-content">
|
||||
<div className="dashboard-section-header">
|
||||
<h2>Projekte</h2>
|
||||
<button className="dashboard-create-btn" onClick={() => setShowCreate(!showCreate)}>
|
||||
+ Neues Projekt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="dashboard-create-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Projektname"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Beschreibung (optional)"
|
||||
value={newDesc}
|
||||
onChange={e => setNewDesc(e.target.value)}
|
||||
/>
|
||||
<button onClick={handleCreate}>Erstellen</button>
|
||||
<button onClick={() => setShowCreate(false)}>Abbrechen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="dashboard-loading">Lade Projekte…</p>
|
||||
) : projects.length === 0 ? (
|
||||
<p className="dashboard-empty">Noch keine Projekte. Erstellen Sie ein neues Projekt.</p>
|
||||
) : (
|
||||
<div className="dashboard-project-grid">
|
||||
{projects.map(project => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="dashboard-project-card"
|
||||
onClick={() => onOpenProject(project.id)}
|
||||
>
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p>{project.description}</p>}
|
||||
<div className="dashboard-project-meta">
|
||||
<span>Erstellt: {new Date(project.created_at).toLocaleDateString('de-DE')}</span>
|
||||
<button
|
||||
className="dashboard-delete-btn"
|
||||
onClick={(e) => handleDelete(project.id, e)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Login Page – Email/Password login
|
||||
*/
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface LoginProps {
|
||||
onSwitchToRegister: () => void;
|
||||
}
|
||||
|
||||
export function Login({ onSwitchToRegister }: LoginProps) {
|
||||
const { login, loading, error, clearError } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch {
|
||||
// error is set in context
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">Web CAD</h1>
|
||||
<p className="auth-subtitle">Anmelden</p>
|
||||
|
||||
{error && (
|
||||
<div className="auth-error" onClick={clearError}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="auth-field">
|
||||
<label htmlFor="email">E-Mail</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="name@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-field">
|
||||
<label htmlFor="password">Passwort</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="auth-button" disabled={loading}>
|
||||
{loading ? 'Wird angemeldet…' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="auth-switch">
|
||||
Noch kein Konto?{' '}
|
||||
<button type="button" className="auth-link" onClick={onSwitchToRegister}>
|
||||
Registrieren
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Register Page – New user registration
|
||||
*/
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface RegisterProps {
|
||||
onSwitchToLogin: () => void;
|
||||
}
|
||||
|
||||
export function Register({ onSwitchToLogin }: RegisterProps) {
|
||||
const { register, loading, error, clearError } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLocalError(null);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setLocalError('Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setLocalError('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register(email, password, name);
|
||||
} catch {
|
||||
// error is set in context
|
||||
}
|
||||
};
|
||||
|
||||
const displayError = localError || error;
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">Web CAD</h1>
|
||||
<p className="auth-subtitle">Registrieren</p>
|
||||
|
||||
{displayError && (
|
||||
<div className="auth-error" onClick={() => { clearError(); setLocalError(null); }}>
|
||||
{displayError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="auth-field">
|
||||
<label htmlFor="reg-name">Name</label>
|
||||
<input
|
||||
id="reg-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="Ihr Name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-field">
|
||||
<label htmlFor="reg-email">E-Mail</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="name@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-field">
|
||||
<label htmlFor="reg-password">Passwort</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="min. 6 Zeichen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-field">
|
||||
<label htmlFor="reg-confirm">Passwort bestätigen</label>
|
||||
<input
|
||||
id="reg-confirm"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="auth-button" disabled={loading}>
|
||||
{loading ? 'Wird registriert…' : 'Registrieren'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="auth-switch">
|
||||
Bereits ein Konto?{' '}
|
||||
<button type="button" className="auth-link" onClick={onSwitchToLogin}>
|
||||
Anmelden
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* PluginRegistry – Manages plugin registration, lifecycle, and extension lookups.
|
||||
*/
|
||||
import type {
|
||||
Plugin,
|
||||
PluginContext,
|
||||
PluginState,
|
||||
ElementTypeExtension,
|
||||
ToolExtension,
|
||||
CommandExtension,
|
||||
ImportExportExtension,
|
||||
} from './types';
|
||||
|
||||
class PluginRegistryClass {
|
||||
private plugins = new Map<string, Plugin>();
|
||||
private states = new Map<string, PluginState>();
|
||||
private context: PluginContext | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
/** Set the plugin context (called once on app init) */
|
||||
setContext(ctx: PluginContext) {
|
||||
this.context = ctx;
|
||||
}
|
||||
|
||||
/** Register a plugin */
|
||||
register(plugin: Plugin) {
|
||||
const { id } = plugin.manifest;
|
||||
if (this.plugins.has(id)) {
|
||||
console.warn(`[PluginRegistry] Plugin '${id}' already registered`);
|
||||
return;
|
||||
}
|
||||
this.plugins.set(id, plugin);
|
||||
this.states.set(id, {
|
||||
manifest: plugin.manifest,
|
||||
enabled: plugin.manifest.enabledByDefault ?? false,
|
||||
loaded: false,
|
||||
});
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Enable and activate a plugin */
|
||||
enable(pluginId: string) {
|
||||
const plugin = this.plugins.get(pluginId);
|
||||
const state = this.states.get(pluginId);
|
||||
if (!plugin || !state || !this.context) return;
|
||||
|
||||
state.enabled = true;
|
||||
if (!state.loaded) {
|
||||
plugin.onInit?.(this.context);
|
||||
state.loaded = true;
|
||||
}
|
||||
plugin.onActivate?.(this.context);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Disable and deactivate a plugin */
|
||||
disable(pluginId: string) {
|
||||
const plugin = this.plugins.get(pluginId);
|
||||
const state = this.states.get(pluginId);
|
||||
if (!plugin || !state) return;
|
||||
|
||||
state.enabled = false;
|
||||
plugin.onDeactivate?.();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Toggle plugin enabled state */
|
||||
toggle(pluginId: string) {
|
||||
const state = this.states.get(pluginId);
|
||||
if (!state) return;
|
||||
if (state.enabled) {
|
||||
this.disable(pluginId);
|
||||
} else {
|
||||
this.enable(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Unregister a plugin */
|
||||
unregister(pluginId: string) {
|
||||
const plugin = this.plugins.get(pluginId);
|
||||
if (plugin) {
|
||||
plugin.onDestroy?.();
|
||||
}
|
||||
this.plugins.delete(pluginId);
|
||||
this.states.delete(pluginId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Get all plugin states */
|
||||
getStates(): PluginState[] {
|
||||
return Array.from(this.states.values());
|
||||
}
|
||||
|
||||
/** Get a specific plugin */
|
||||
getPlugin(pluginId: string): Plugin | undefined {
|
||||
return this.plugins.get(pluginId);
|
||||
}
|
||||
|
||||
/** Get all enabled plugins */
|
||||
getEnabledPlugins(): Plugin[] {
|
||||
const result: Plugin[] = [];
|
||||
for (const [id, plugin] of this.plugins) {
|
||||
const state = this.states.get(id);
|
||||
if (state?.enabled) result.push(plugin);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get all element type extensions from enabled plugins */
|
||||
getElementTypeExtensions(): ElementTypeExtension[] {
|
||||
const extensions: ElementTypeExtension[] = [];
|
||||
for (const plugin of this.getEnabledPlugins()) {
|
||||
if (plugin.elementTypes) extensions.push(...plugin.elementTypes);
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/** Get all tool extensions from enabled plugins */
|
||||
getToolExtensions(): ToolExtension[] {
|
||||
const extensions: ToolExtension[] = [];
|
||||
for (const plugin of this.getEnabledPlugins()) {
|
||||
if (plugin.tools) extensions.push(...plugin.tools);
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/** Get all command extensions from enabled plugins */
|
||||
getCommandExtensions(): CommandExtension[] {
|
||||
const extensions: CommandExtension[] = [];
|
||||
for (const plugin of this.getEnabledPlugins()) {
|
||||
if (plugin.commands) extensions.push(...plugin.commands);
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/** Get all import/export extensions from enabled plugins */
|
||||
getImportExportExtensions(): ImportExportExtension[] {
|
||||
const extensions: ImportExportExtension[] = [];
|
||||
for (const plugin of this.getEnabledPlugins()) {
|
||||
if (plugin.importExport) extensions.push(...plugin.importExport);
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/** Find an element type extension by type name */
|
||||
getElementType(typeName: string): ElementTypeExtension | undefined {
|
||||
return this.getElementTypeExtensions().find((e) => e.typeName === typeName);
|
||||
}
|
||||
|
||||
/** Subscribe to state changes */
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** Initialize all plugins that are enabled by default */
|
||||
initDefaults() {
|
||||
for (const [id, plugin] of this.plugins) {
|
||||
if (plugin.manifest.enabledByDefault) {
|
||||
this.enable(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginRegistry = new PluginRegistryClass();
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Event-Tools Plugin – Built-in example plugin
|
||||
* Adds custom element types: stage-curtain, spotlight, barrier
|
||||
* Adds command: EVENT_SEATING (generates seating rows)
|
||||
*/
|
||||
import type { Plugin, PluginContext, ElementTypeExtension, CommandExtension } from '../types';
|
||||
import type { CADElement } from '../../types/cad.types';
|
||||
|
||||
function uid(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
}
|
||||
|
||||
// ─── Element Type: Stage Curtain ────────────────────────
|
||||
const stageCurtain: ElementTypeExtension = {
|
||||
typeName: 'stage-curtain',
|
||||
displayName: 'Bühnenvorhang',
|
||||
defaultWidth: 6,
|
||||
defaultHeight: 0.3,
|
||||
defaultProperties: {
|
||||
fill: '#8B0000',
|
||||
stroke: '#5C0000',
|
||||
strokeWidth: 2,
|
||||
curtainStyle: 'pleated',
|
||||
},
|
||||
render(ctx, element, scale) {
|
||||
const { x, y, width, height, properties } = element;
|
||||
ctx.save();
|
||||
ctx.fillStyle = (properties.fill as string) || '#8B0000';
|
||||
ctx.strokeStyle = (properties.stroke as string) || '#5C0000';
|
||||
ctx.lineWidth = (properties.strokeWidth as number) || 2;
|
||||
|
||||
// Draw pleated curtain
|
||||
const pleats = Math.max(6, Math.floor(width / 0.5));
|
||||
const pleatWidth = width / pleats;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
for (let i = 0; i <= pleats; i++) {
|
||||
const px = x + i * pleatWidth;
|
||||
const py = y + (i % 2 === 0 ? 0 : height * 0.15);
|
||||
ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.lineTo(x + width, y + height);
|
||||
ctx.lineTo(x, y + height);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
return true;
|
||||
},
|
||||
hitTest(element, hx, hy, tolerance) {
|
||||
const { x, y, width, height } = element;
|
||||
return hx >= x - tolerance && hx <= x + width + tolerance &&
|
||||
hy >= y - tolerance && hy <= y + height + tolerance;
|
||||
},
|
||||
propertyFields: [
|
||||
{ key: 'fill', label: 'Farbe', type: 'color' },
|
||||
{ key: 'curtainStyle', label: 'Stil', type: 'select', options: [
|
||||
{ value: 'pleated', label: 'Gefaltet' },
|
||||
{ value: 'flat', label: 'Glatt' },
|
||||
]},
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Element Type: Spotlight ────────────────────────────
|
||||
const spotlight: ElementTypeExtension = {
|
||||
typeName: 'spotlight',
|
||||
displayName: 'Scheinwerfer',
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
defaultProperties: {
|
||||
fill: 'rgba(255, 220, 100, 0.3)',
|
||||
stroke: '#FFD700',
|
||||
strokeWidth: 1.5,
|
||||
beamAngle: 45,
|
||||
},
|
||||
render(ctx, element, scale) {
|
||||
const { x, y, width, height, properties } = element;
|
||||
ctx.save();
|
||||
const cx = x + width / 2;
|
||||
const cy = y + height / 2;
|
||||
const radius = Math.max(width, height) / 2;
|
||||
|
||||
// Draw beam cone
|
||||
const beamAngle = ((properties.beamAngle as number) || 45) * Math.PI / 180;
|
||||
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||||
gradient.addColorStop(0, 'rgba(255, 220, 100, 0.5)');
|
||||
gradient.addColorStop(1, 'rgba(255, 220, 100, 0.05)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.arc(cx, cy, radius, -Math.PI / 2 - beamAngle / 2, -Math.PI / 2 + beamAngle / 2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Draw fixture circle
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.strokeStyle = (properties.stroke as string) || '#FFD700';
|
||||
ctx.lineWidth = (properties.strokeWidth as number) || 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 0.15 * scale, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
return true;
|
||||
},
|
||||
hitTest(element, hx, hy, tolerance) {
|
||||
const cx = element.x + element.width / 2;
|
||||
const cy = element.y + element.height / 2;
|
||||
const radius = Math.max(element.width, element.height) / 2 + tolerance;
|
||||
const dx = hx - cx;
|
||||
const dy = hy - cy;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
},
|
||||
propertyFields: [
|
||||
{ key: 'beamAngle', label: 'Strahlwinkel°', type: 'number', min: 10, max: 180, step: 5 },
|
||||
{ key: 'stroke', label: 'Farbe', type: 'color' },
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Element Type: Barrier ──────────────────────────────
|
||||
const barrier: ElementTypeExtension = {
|
||||
typeName: 'barrier',
|
||||
displayName: 'Absperrung',
|
||||
defaultWidth: 3,
|
||||
defaultHeight: 0.1,
|
||||
defaultProperties: {
|
||||
fill: '#FFA500',
|
||||
stroke: '#CC8400',
|
||||
strokeWidth: 1.5,
|
||||
pattern: 'striped',
|
||||
},
|
||||
render(ctx, element, scale) {
|
||||
const { x, y, width, height, properties } = element;
|
||||
ctx.save();
|
||||
ctx.fillStyle = (properties.fill as string) || '#FFA500';
|
||||
ctx.strokeStyle = (properties.stroke as string) || '#CC8400';
|
||||
ctx.lineWidth = (properties.strokeWidth as number) || 1.5;
|
||||
|
||||
// Draw striped barrier
|
||||
const stripeWidth = 0.3;
|
||||
const stripes = Math.floor(width / stripeWidth);
|
||||
for (let i = 0; i < stripes; i++) {
|
||||
ctx.fillStyle = i % 2 === 0 ? '#FFA500' : '#000000';
|
||||
ctx.fillRect(x + i * stripeWidth, y, stripeWidth, height);
|
||||
}
|
||||
ctx.strokeStyle = (properties.stroke as string) || '#CC8400';
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
ctx.restore();
|
||||
return true;
|
||||
},
|
||||
hitTest(element, hx, hy, tolerance) {
|
||||
const { x, y, width, height } = element;
|
||||
return hx >= x - tolerance && hx <= x + width + tolerance &&
|
||||
hy >= y - tolerance && hy <= y + height + tolerance;
|
||||
},
|
||||
propertyFields: [
|
||||
{ key: 'fill', label: 'Farbe', type: 'color' },
|
||||
{ key: 'pattern', label: 'Muster', type: 'select', options: [
|
||||
{ value: 'striped', label: 'Gestreift' },
|
||||
{ value: 'solid', label: 'Einfarbig' },
|
||||
]},
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Command: EVENT_SEATING ─────────────────────────────
|
||||
const eventSeatingCommand: CommandExtension = {
|
||||
name: 'EVENT_SEATING',
|
||||
description: 'Erzeugt Bestuhlung in Reihen',
|
||||
usage: 'EVENT_SEATING <rows> <cols> [gap] [rowGap]',
|
||||
execute(args, context) {
|
||||
const rows = parseInt(args[0] || '5', 10);
|
||||
const cols = parseInt(args[1] || '10', 10);
|
||||
const gap = parseFloat(args[2] || '0.6');
|
||||
const rowGap = parseFloat(args[3] || '1.0');
|
||||
const layerId = context.getActiveLayerId();
|
||||
const chairWidth = 0.5;
|
||||
const chairHeight = 0.5;
|
||||
const startX = 2;
|
||||
const startY = 2;
|
||||
|
||||
let count = 0;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const el: CADElement = {
|
||||
id: uid('chair'),
|
||||
type: 'chair',
|
||||
layerId,
|
||||
x: startX + c * (chairWidth + gap),
|
||||
y: startY + r * (chairHeight + rowGap),
|
||||
width: chairWidth,
|
||||
height: chairHeight,
|
||||
properties: { fill: '#4A90D9', stroke: '#2A70B9', strokeWidth: 1, rotation: 0 },
|
||||
};
|
||||
context.addElement(el);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
context.showToast(`${count} Stühle in ${rows} Reihen erstellt`, 'success');
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Plugin Definition ──────────────────────────────────
|
||||
export const eventToolsPlugin: Plugin = {
|
||||
manifest: {
|
||||
id: 'event-tools',
|
||||
name: 'Event-Tools',
|
||||
version: '1.0.0',
|
||||
author: 'Web CAD Team',
|
||||
description: 'Erweitert Web CAD um Bühnenvorhänge, Scheinwerfer, Absperrungen und Bestuhlungs-Befehle.',
|
||||
category: 'elements',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
elementTypes: [stageCurtain, spotlight, barrier],
|
||||
commands: [eventSeatingCommand],
|
||||
onInit(context) {
|
||||
context.log('Event-Tools Plugin initialisiert');
|
||||
},
|
||||
onActivate(context) {
|
||||
context.log('Event-Tools Plugin aktiviert');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Plugin System – Public API
|
||||
*/
|
||||
export { pluginRegistry } from './PluginRegistry';
|
||||
export type {
|
||||
Plugin,
|
||||
PluginManifest,
|
||||
PluginContext,
|
||||
PluginState,
|
||||
ElementTypeExtension,
|
||||
ToolExtension,
|
||||
CommandExtension,
|
||||
ImportExportExtension,
|
||||
PropertyField,
|
||||
} from './types';
|
||||
|
||||
// Built-in plugins
|
||||
export { eventToolsPlugin } from './builtin/eventTools';
|
||||
|
||||
import { pluginRegistry } from './PluginRegistry';
|
||||
import { eventToolsPlugin } from './builtin/eventTools';
|
||||
|
||||
/** Register all built-in plugins */
|
||||
export function registerBuiltinPlugins() {
|
||||
pluginRegistry.register(eventToolsPlugin);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Plugin System Types – Manifest, Extension Points, Lifecycle
|
||||
*/
|
||||
import type { CADElement, CADLayer } from '../types/cad.types';
|
||||
|
||||
// ─── Plugin Manifest ────────────────────────────────────
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
category: 'tools' | 'elements' | 'import-export' | 'theme' | 'other';
|
||||
enabledByDefault?: boolean;
|
||||
}
|
||||
|
||||
// ─── Extension Points ───────────────────────────────────
|
||||
|
||||
/** Custom element type with renderer */
|
||||
export interface ElementTypeExtension {
|
||||
typeName: string;
|
||||
displayName: string;
|
||||
icon?: string;
|
||||
defaultWidth: number;
|
||||
defaultHeight: number;
|
||||
defaultProperties: Record<string, unknown>;
|
||||
/** Render element on canvas context. Return true if handled. */
|
||||
render?: (ctx: CanvasRenderingContext2D, element: CADElement, scale: number) => boolean;
|
||||
/** Optional hit-test for selection */
|
||||
hitTest?: (element: CADElement, x: number, y: number, tolerance: number) => boolean;
|
||||
/** Optional property panel fields */
|
||||
propertyFields?: PropertyField[];
|
||||
}
|
||||
|
||||
/** Custom tool that appears in ribbon bar */
|
||||
export interface ToolExtension {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
ribbonTab: string;
|
||||
tooltip?: string;
|
||||
shortcut?: string;
|
||||
onActivate: (context: PluginContext) => void;
|
||||
}
|
||||
|
||||
/** Property panel field definition */
|
||||
export interface PropertyField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'color' | 'select' | 'checkbox';
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
/** Custom command-line command */
|
||||
export interface CommandExtension {
|
||||
name: string;
|
||||
description: string;
|
||||
usage: string;
|
||||
execute: (args: string[], context: PluginContext) => void;
|
||||
}
|
||||
|
||||
/** Custom import/export format */
|
||||
export interface ImportExportExtension {
|
||||
format: string;
|
||||
extension: string;
|
||||
label: string;
|
||||
import?: (data: string, context: PluginContext) => CADElement[];
|
||||
export?: (elements: CADElement[], layers: CADLayer[], context: PluginContext) => string;
|
||||
}
|
||||
|
||||
// ─── Plugin Context (API for plugins) ───────────────────
|
||||
export interface PluginContext {
|
||||
/** Add element to current drawing */
|
||||
addElement: (element: CADElement) => void;
|
||||
/** Remove element by ID */
|
||||
removeElement: (id: string) => void;
|
||||
/** Update element properties */
|
||||
updateElement: (id: string, properties: Partial<CADElement>) => void;
|
||||
/** Get all elements */
|
||||
getElements: () => CADElement[];
|
||||
/** Get all layers */
|
||||
getLayers: () => CADLayer[];
|
||||
/** Get active layer ID */
|
||||
getActiveLayerId: () => string;
|
||||
/** Show status message */
|
||||
showToast: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => void;
|
||||
/** Log to console with plugin prefix */
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
// ─── Plugin Interface ───────────────────────────────────
|
||||
export interface Plugin {
|
||||
manifest: PluginManifest;
|
||||
/** Called when plugin is loaded */
|
||||
onInit?: (context: PluginContext) => void;
|
||||
/** Called when plugin is activated */
|
||||
onActivate?: (context: PluginContext) => void;
|
||||
/** Called when plugin is deactivated */
|
||||
onDeactivate?: () => void;
|
||||
/** Called on plugin unload */
|
||||
onDestroy?: () => void;
|
||||
/** Extension points */
|
||||
elementTypes?: ElementTypeExtension[];
|
||||
tools?: ToolExtension[];
|
||||
commands?: CommandExtension[];
|
||||
importExport?: ImportExportExtension[];
|
||||
}
|
||||
|
||||
// ─── Plugin State ───────────────────────────────────────
|
||||
export interface PluginState {
|
||||
manifest: PluginManifest;
|
||||
enabled: boolean;
|
||||
loaded: boolean;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* API Service – Backend communication for projects, drawings, elements, layers, blocks
|
||||
*/
|
||||
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:3001';
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Drawing {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────
|
||||
export async function login(email: string, password: string): Promise<{ user: any; session: { token: string } }> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Login failed');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function register(email: string, password: string, name: string): Promise<{ user: any; session: { token: string } }> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, name }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Registration failed');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getMe(token: string): Promise<any> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (!res.ok) throw new Error('Not authenticated');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Projects ───────────────────────────────────────────
|
||||
export async function getProjects(token: string): Promise<Project[]> {
|
||||
const res = await fetch(`${API_BASE}/api/projects`, { headers: authHeaders(token) });
|
||||
if (!res.ok) throw new Error('Failed to load projects');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createProject(token: string, name: string, description?: string): Promise<Project> {
|
||||
const res = await fetch(`${API_BASE}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ name, description: description || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create project');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteProject(token: string, id: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/api/projects/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Drawings ───────────────────────────────────────────
|
||||
export async function getDrawings(token: string, projectId: string): Promise<Drawing[]> {
|
||||
const res = await fetch(`${API_BASE}/api/projects/${projectId}/drawings`, { headers: authHeaders(token) });
|
||||
if (!res.ok) throw new Error('Failed to load drawings');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createDrawing(token: string, projectId: string, name: string): Promise<Drawing> {
|
||||
const res = await fetch(`${API_BASE}/api/projects/${projectId}/drawings`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create drawing');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Elements ───────────────────────────────────────────
|
||||
export async function getElements(token: string, drawingId: string): Promise<CADElement[]> {
|
||||
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/elements`, { headers: authHeaders(token) });
|
||||
if (!res.ok) throw new Error('Failed to load elements');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createElement(token: string, drawingId: string, el: CADElement): Promise<CADElement> {
|
||||
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/elements`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify(el),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create element');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateElement(token: string, elementId: string, patch: Partial<CADElement>): Promise<CADElement> {
|
||||
const res = await fetch(`${API_BASE}/api/elements/${elementId}`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update element');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteElement(token: string, elementId: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/api/elements/${elementId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Layers ─────────────────────────────────────────────
|
||||
export async function getLayers(token: string, drawingId: string): Promise<CADLayer[]> {
|
||||
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/layers`, { headers: authHeaders(token) });
|
||||
if (!res.ok) throw new Error('Failed to load layers');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createLayer(token: string, drawingId: string, layer: CADLayer): Promise<CADLayer> {
|
||||
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/layers`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify(layer),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create layer');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateLayer(token: string, layerId: string, patch: Partial<CADLayer>): Promise<CADLayer> {
|
||||
const res = await fetch(`${API_BASE}/api/layers/${layerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update layer');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteLayer(token: string, layerId: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/api/layers/${layerId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Blocks ─────────────────────────────────────────────
|
||||
export async function getBlocks(token: string, drawingId: string): Promise<BlockDefinition[]> {
|
||||
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/blocks`, { headers: authHeaders(token) });
|
||||
if (!res.ok) throw new Error('Failed to load blocks');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createBlock(token: string, drawingId: string, block: BlockDefinition): Promise<BlockDefinition> {
|
||||
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/blocks`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify(block),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create block');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateBlock(token: string, blockId: string, patch: Partial<BlockDefinition>): Promise<BlockDefinition> {
|
||||
const res = await fetch(`${API_BASE}/api/blocks/${blockId}`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update block');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteBlock(token: string, blockId: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/api/blocks/${blockId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Composite: Load full project data ───────────────────
|
||||
export interface ProjectData {
|
||||
project: Project;
|
||||
drawing: Drawing | null;
|
||||
elements: CADElement[];
|
||||
layers: CADLayer[];
|
||||
blocks: BlockDefinition[];
|
||||
}
|
||||
|
||||
export async function loadProjectData(token: string, projectId: string): Promise<ProjectData> {
|
||||
const drawings = await getDrawings(token, projectId);
|
||||
const project = (await getProjects(token)).find(p => p.id === projectId);
|
||||
|
||||
if (!project) throw new Error('Project not found');
|
||||
|
||||
// Use first drawing or create one
|
||||
let drawing = drawings[0] || null;
|
||||
if (!drawing) {
|
||||
drawing = await createDrawing(token, projectId, 'Hauptzeichnung');
|
||||
}
|
||||
|
||||
const [elements, layers, blocks] = await Promise.all([
|
||||
getElements(token, drawing.id),
|
||||
getLayers(token, drawing.id),
|
||||
getBlocks(token, drawing.id),
|
||||
]);
|
||||
|
||||
return { project, drawing, elements, layers, blocks };
|
||||
}
|
||||
|
||||
// ─── Format Conversion: DB (snake_case) ↔ Frontend (camelCase) ─────
|
||||
|
||||
function dbElementToFrontend(dbEl: any): CADElement {
|
||||
return {
|
||||
id: dbEl.id,
|
||||
type: dbEl.type,
|
||||
layerId: dbEl.layer_id,
|
||||
x: dbEl.x,
|
||||
y: dbEl.y,
|
||||
width: dbEl.width,
|
||||
height: dbEl.height,
|
||||
properties: typeof dbEl.properties_json === 'string'
|
||||
? JSON.parse(dbEl.properties_json)
|
||||
: (dbEl.properties_json || {}),
|
||||
};
|
||||
}
|
||||
|
||||
function frontendElementToDb(el: CADElement, drawingId: string): any {
|
||||
return {
|
||||
id: el.id,
|
||||
drawing_id: drawingId,
|
||||
layer_id: el.layerId,
|
||||
type: el.type,
|
||||
x: el.x,
|
||||
y: el.y,
|
||||
width: el.width,
|
||||
height: el.height,
|
||||
properties_json: JSON.stringify(el.properties),
|
||||
};
|
||||
}
|
||||
|
||||
function dbLayerToFrontend(dbLayer: any): CADLayer {
|
||||
return {
|
||||
id: dbLayer.id,
|
||||
name: dbLayer.name,
|
||||
visible: !!dbLayer.visible,
|
||||
locked: !!dbLayer.locked,
|
||||
color: dbLayer.color,
|
||||
lineType: dbLayer.line_type as 'solid' | 'dashed' | 'dotted',
|
||||
transparency: dbLayer.transparency,
|
||||
sortOrder: dbLayer.sort_order,
|
||||
parentId: dbLayer.parent_id,
|
||||
};
|
||||
}
|
||||
|
||||
function frontendLayerToDb(layer: CADLayer, drawingId: string): any {
|
||||
return {
|
||||
id: layer.id,
|
||||
drawing_id: drawingId,
|
||||
name: layer.name,
|
||||
visible: layer.visible ? 1 : 0,
|
||||
locked: layer.locked ? 1 : 0,
|
||||
color: layer.color,
|
||||
line_type: layer.lineType,
|
||||
transparency: layer.transparency,
|
||||
sort_order: layer.sortOrder,
|
||||
parent_id: layer.parentId,
|
||||
};
|
||||
}
|
||||
|
||||
function dbBlockToFrontend(dbBlock: any): BlockDefinition {
|
||||
return {
|
||||
id: dbBlock.id,
|
||||
name: dbBlock.name,
|
||||
description: dbBlock.description || '',
|
||||
category: dbBlock.category,
|
||||
elements: typeof dbBlock.elements_json === 'string'
|
||||
? JSON.parse(dbBlock.elements_json)
|
||||
: (dbBlock.elements_json || []),
|
||||
thumbnail: dbBlock.thumbnail || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function frontendBlockToDb(block: BlockDefinition, drawingId: string): any {
|
||||
return {
|
||||
id: block.id,
|
||||
drawing_id: drawingId,
|
||||
name: block.name,
|
||||
description: block.description,
|
||||
category: block.category,
|
||||
elements_json: JSON.stringify(block.elements),
|
||||
thumbnail: block.thumbnail || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Typed API calls with conversion ─────────────────────
|
||||
|
||||
export async function getElementsTyped(token: string, drawingId: string): Promise<CADElement[]> {
|
||||
const raw = await getElements(token, drawingId);
|
||||
return raw.map(dbElementToFrontend);
|
||||
}
|
||||
|
||||
export async function createElementTyped(token: string, drawingId: string, el: CADElement): Promise<CADElement> {
|
||||
const raw = await createElement(token, drawingId, frontendElementToDb(el, drawingId));
|
||||
return dbElementToFrontend(raw);
|
||||
}
|
||||
|
||||
export async function getLayersTyped(token: string, drawingId: string): Promise<CADLayer[]> {
|
||||
const raw = await getLayers(token, drawingId);
|
||||
return raw.map(dbLayerToFrontend);
|
||||
}
|
||||
|
||||
export async function createLayerTyped(token: string, drawingId: string, layer: CADLayer): Promise<CADLayer> {
|
||||
const raw = await createLayer(token, drawingId, frontendLayerToDb(layer, drawingId));
|
||||
return dbLayerToFrontend(raw);
|
||||
}
|
||||
|
||||
export async function getBlocksTyped(token: string, drawingId: string): Promise<BlockDefinition[]> {
|
||||
const raw = await getBlocks(token, drawingId);
|
||||
return raw.map(dbBlockToFrontend);
|
||||
}
|
||||
|
||||
export async function createBlockTyped(token: string, drawingId: string, block: BlockDefinition): Promise<BlockDefinition> {
|
||||
const raw = await createBlock(token, drawingId, frontendBlockToDb(block, drawingId));
|
||||
return dbBlockToFrontend(raw);
|
||||
}
|
||||
|
||||
const projectLoadCache = new Map<string, Promise<ProjectData>>();
|
||||
|
||||
export async function loadProjectDataTyped(token: string, projectId: string): Promise<ProjectData> {
|
||||
// Dedup concurrent calls (React StrictMode double-render)
|
||||
const existing = projectLoadCache.get(projectId);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = (async () => {
|
||||
const drawings = await getDrawings(token, projectId);
|
||||
const projects = await getProjects(token);
|
||||
const project = projects.find(p => p.id === projectId);
|
||||
if (!project) throw new Error('Project not found');
|
||||
|
||||
let drawing = drawings[0] || null;
|
||||
if (!drawing) {
|
||||
drawing = await createDrawing(token, projectId, 'Hauptzeichnung');
|
||||
}
|
||||
|
||||
const [elementsRaw, layersRaw, blocksRaw] = await Promise.all([
|
||||
getElements(token, drawing.id),
|
||||
getLayers(token, drawing.id),
|
||||
getBlocks(token, drawing.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
project,
|
||||
drawing,
|
||||
elements: elementsRaw.map(dbElementToFrontend),
|
||||
layers: layersRaw.map(dbLayerToFrontend),
|
||||
blocks: blocksRaw.map(dbBlockToFrontend),
|
||||
};
|
||||
})();
|
||||
|
||||
projectLoadCache.set(projectId, promise);
|
||||
promise.finally(() => projectLoadCache.delete(projectId));
|
||||
return promise;
|
||||
}
|
||||
|
||||
export { API_BASE };
|
||||
|
||||
// ─── AI Copilot ─────────────────────────────────────────
|
||||
export interface AIChatMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AIChatContext {
|
||||
projectName?: string;
|
||||
elementCount?: number;
|
||||
layerCount?: number;
|
||||
elementTypeSummary?: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function aiChat(
|
||||
token: string,
|
||||
messages: AIChatMessage[],
|
||||
context?: AIChatContext
|
||||
): Promise<{ content: string; suggestions?: string[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/ai/chat`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ messages, context }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'AI request failed' }));
|
||||
throw new Error(err.error || 'AI request failed');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { ProjectData } from '../types/cad.types';
|
||||
|
||||
/**
|
||||
* Background Service — manages background image loading, positioning, scaling, and calibration.
|
||||
* Supports PNG, JPG, SVG as background images.
|
||||
*/
|
||||
|
||||
export interface BackgroundConfig {
|
||||
src: string; // data URL or path to image
|
||||
name: string; // original file name
|
||||
format: 'png' | 'jpg' | 'svg' | 'pdf';
|
||||
width: number; // natural image width in pixels
|
||||
height: number; // natural image height in pixels
|
||||
scale: number; // pixels per world-unit (e.g. 1px = 0.01m → scale=100)
|
||||
offsetX: number; // world X offset
|
||||
offsetY: number; // world Y offset
|
||||
rotation: number; // rotation in degrees
|
||||
visible: boolean;
|
||||
opacity: number; // 0-1
|
||||
}
|
||||
|
||||
export const DEFAULT_BACKGROUND: BackgroundConfig = {
|
||||
src: '',
|
||||
name: '',
|
||||
format: 'png',
|
||||
width: 0,
|
||||
height: 0,
|
||||
scale: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
rotation: 0,
|
||||
visible: true,
|
||||
opacity: 0.5,
|
||||
};
|
||||
|
||||
export interface CalibrationResult {
|
||||
scale: number; // computed pixels per world-unit
|
||||
unit: string; // 'm' | 'cm' | 'mm'
|
||||
}
|
||||
|
||||
export class BackgroundService {
|
||||
private image: HTMLImageElement | null = null;
|
||||
private config: BackgroundConfig = { ...DEFAULT_BACKGROUND };
|
||||
|
||||
/** Load an image file and return its config */
|
||||
async loadFromFile(file: File): Promise<BackgroundConfig> {
|
||||
const format = this.detectFormat(file);
|
||||
const src = await this.fileToDataURL(file);
|
||||
const { width, height } = await this.getImageDimensions(src);
|
||||
|
||||
this.config = {
|
||||
...DEFAULT_BACKGROUND,
|
||||
src,
|
||||
name: file.name,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
this.image = new Image();
|
||||
this.image.src = src;
|
||||
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/** Load from a URL or data string */
|
||||
async loadFromSrc(src: string, name: string = 'background'): Promise<BackgroundConfig> {
|
||||
const { width, height } = await this.getImageDimensions(src);
|
||||
const format = this.detectFormatFromSrc(src);
|
||||
|
||||
this.config = {
|
||||
...DEFAULT_BACKGROUND,
|
||||
src,
|
||||
name,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
this.image = new Image();
|
||||
this.image.src = src;
|
||||
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/** Get the current background config */
|
||||
getConfig(): BackgroundConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/** Update background config */
|
||||
updateConfig(partial: Partial<BackgroundConfig>): BackgroundConfig {
|
||||
this.config = { ...this.config, ...partial };
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
/** Set visibility */
|
||||
setVisible(visible: boolean): void {
|
||||
this.config.visible = visible;
|
||||
}
|
||||
|
||||
/** Set opacity (0-1) */
|
||||
setOpacity(opacity: number): void {
|
||||
this.config.opacity = Math.max(0, Math.min(1, opacity));
|
||||
}
|
||||
|
||||
/** Move background by delta */
|
||||
move(dx: number, dy: number): void {
|
||||
this.config.offsetX += dx;
|
||||
this.config.offsetY += dy;
|
||||
}
|
||||
|
||||
/** Set position directly */
|
||||
setPosition(x: number, y: number): void {
|
||||
this.config.offsetX = x;
|
||||
this.config.offsetY = y;
|
||||
}
|
||||
|
||||
/** Rotate background by delta degrees */
|
||||
rotate(deltaAngle: number): void {
|
||||
this.config.rotation += deltaAngle;
|
||||
}
|
||||
|
||||
/** Set rotation directly */
|
||||
setRotation(angle: number): void {
|
||||
this.config.rotation = angle;
|
||||
}
|
||||
|
||||
/** Scale background by factor */
|
||||
scaleBy(factor: number): void {
|
||||
this.config.scale *= factor;
|
||||
}
|
||||
|
||||
/** Set scale directly */
|
||||
setScale(scale: number): void {
|
||||
this.config.scale = Math.max(0.001, scale);
|
||||
}
|
||||
|
||||
/** Calibrate scale using a reference distance
|
||||
* @param pixelDistance - measured distance in pixels between two points on the image
|
||||
* @param realDistance - known real-world distance
|
||||
* @param unit - unit of realDistance ('m', 'cm', 'mm')
|
||||
* @returns computed scale (pixels per world-unit)
|
||||
*/
|
||||
calibrateScale(pixelDistance: number, realDistance: number, unit: string = 'm'): CalibrationResult {
|
||||
if (realDistance <= 0 || pixelDistance <= 0) {
|
||||
return { scale: this.config.scale, unit };
|
||||
}
|
||||
// Convert realDistance to base unit (mm)
|
||||
let realMm = realDistance;
|
||||
switch (unit) {
|
||||
case 'm': realMm = realDistance * 1000; break;
|
||||
case 'cm': realMm = realDistance * 10; break;
|
||||
case 'mm': realMm = realDistance; break;
|
||||
}
|
||||
// scale = pixels per mm
|
||||
const scale = pixelDistance / realMm;
|
||||
this.config.scale = scale;
|
||||
return { scale, unit };
|
||||
}
|
||||
|
||||
/** Get the loaded HTMLImageElement for rendering */
|
||||
getImage(): HTMLImageElement | null {
|
||||
return this.image;
|
||||
}
|
||||
|
||||
/** Check if a background is loaded */
|
||||
isLoaded(): boolean {
|
||||
return this.config.src !== '' && this.image !== null;
|
||||
}
|
||||
|
||||
/** Clear the background */
|
||||
clear(): void {
|
||||
this.config = { ...DEFAULT_BACKGROUND };
|
||||
this.image = null;
|
||||
}
|
||||
|
||||
/** Export to ProjectData.background format */
|
||||
toProjectData(): ProjectData['background'] | undefined {
|
||||
if (!this.isLoaded()) return undefined;
|
||||
return {
|
||||
src: this.config.src,
|
||||
scale: this.config.scale,
|
||||
offsetX: this.config.offsetX,
|
||||
offsetY: this.config.offsetY,
|
||||
rotation: this.config.rotation,
|
||||
};
|
||||
}
|
||||
|
||||
/** Import from ProjectData.background format */
|
||||
fromProjectData(bg: NonNullable<ProjectData['background']>): void {
|
||||
this.config = {
|
||||
...DEFAULT_BACKGROUND,
|
||||
src: bg.src,
|
||||
scale: bg.scale,
|
||||
offsetX: bg.offsetX,
|
||||
offsetY: bg.offsetY,
|
||||
rotation: bg.rotation,
|
||||
};
|
||||
this.image = new Image();
|
||||
this.image.src = bg.src;
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private detectFormat(file: File): BackgroundConfig['format'] {
|
||||
const type = file.type.toLowerCase();
|
||||
if (type.includes('png')) return 'png';
|
||||
if (type.includes('jpeg') || type.includes('jpg')) return 'jpg';
|
||||
if (type.includes('svg')) return 'svg';
|
||||
if (type.includes('pdf')) return 'pdf';
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (ext === 'png') return 'png';
|
||||
if (ext === 'jpg' || ext === 'jpeg') return 'jpg';
|
||||
if (ext === 'svg') return 'svg';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
return 'png';
|
||||
}
|
||||
|
||||
private detectFormatFromSrc(src: string): BackgroundConfig['format'] {
|
||||
if (src.startsWith('data:image/png')) return 'png';
|
||||
if (src.startsWith('data:image/jpeg') || src.startsWith('data:image/jpg')) return 'jpg';
|
||||
if (src.startsWith('data:image/svg')) return 'svg';
|
||||
if (src.startsWith('data:application/pdf')) return 'pdf';
|
||||
if (src.endsWith('.png')) return 'png';
|
||||
if (src.endsWith('.jpg') || src.endsWith('.jpeg')) return 'jpg';
|
||||
if (src.endsWith('.svg')) return 'svg';
|
||||
if (src.endsWith('.pdf')) return 'pdf';
|
||||
return 'png';
|
||||
}
|
||||
|
||||
private fileToDataURL(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
private getImageDimensions(src: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import type { BlockDefinition, CADElement } from '../types/cad.types';
|
||||
|
||||
/**
|
||||
* Block Service — CRUD, SVG-Import, Block-Definition vs Referenz
|
||||
*/
|
||||
|
||||
export class BlockService {
|
||||
private blocks: Map<string, BlockDefinition> = new Map();
|
||||
|
||||
/** Register or update a block definition */
|
||||
addBlock(block: BlockDefinition): void {
|
||||
this.blocks.set(block.id, block);
|
||||
}
|
||||
|
||||
/** Remove a block definition */
|
||||
removeBlock(id: string): void {
|
||||
this.blocks.delete(id);
|
||||
}
|
||||
|
||||
/** Get a block definition by ID */
|
||||
getBlock(id: string): BlockDefinition | undefined {
|
||||
return this.blocks.get(id);
|
||||
}
|
||||
|
||||
/** Get all block definitions */
|
||||
getAllBlocks(): BlockDefinition[] {
|
||||
return Array.from(this.blocks.values());
|
||||
}
|
||||
|
||||
/** Get blocks by category */
|
||||
getBlocksByCategory(category: string): BlockDefinition[] {
|
||||
return this.getAllBlocks().filter(b => category === 'Alle' || b.category === category);
|
||||
}
|
||||
|
||||
/** Search blocks by name */
|
||||
searchBlocks(query: string): BlockDefinition[] {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) return this.getAllBlocks();
|
||||
return this.getAllBlocks().filter(b =>
|
||||
b.name.toLowerCase().includes(q) || b.description.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
/** Rename a block definition */
|
||||
renameBlock(id: string, name: string): void {
|
||||
const block = this.blocks.get(id);
|
||||
if (block) {
|
||||
this.blocks.set(id, { ...block, name });
|
||||
}
|
||||
}
|
||||
|
||||
/** Duplicate a block definition */
|
||||
duplicateBlock(id: string): BlockDefinition | null {
|
||||
const block = this.blocks.get(id);
|
||||
if (!block) return null;
|
||||
const newId = `blk-${Date.now()}`;
|
||||
const copy: BlockDefinition = {
|
||||
...block,
|
||||
id: newId,
|
||||
name: `${block.name} (Kopie)`,
|
||||
elements: block.elements.map(el => ({ ...el, id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` })),
|
||||
};
|
||||
this.blocks.set(newId, copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Create a block instance (reference) from a definition */
|
||||
createInstance(blockId: string, x: number, y: number, layerId: string, rotation = 0, scale = 1): CADElement | null {
|
||||
const block = this.blocks.get(blockId);
|
||||
if (!block) return null;
|
||||
|
||||
// Calculate bounding box from elements
|
||||
const bbox = this.getBoundingBox(block.elements);
|
||||
const w = (bbox.maxX - bbox.minX) * scale;
|
||||
const h = (bbox.maxY - bbox.minY) * scale;
|
||||
|
||||
return {
|
||||
id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
type: 'block_instance',
|
||||
layerId,
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
properties: {
|
||||
blockId,
|
||||
rotation,
|
||||
scale,
|
||||
offsetX: -bbox.minX * scale,
|
||||
offsetY: -bbox.minY * scale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Get the elements of a block instance transformed to world coords */
|
||||
getInstanceElements(instance: CADElement): CADElement[] {
|
||||
const blockId = instance.properties.blockId as string;
|
||||
const block = this.blocks.get(blockId);
|
||||
if (!block) return [];
|
||||
|
||||
const rotation = (instance.properties.rotation || 0) * Math.PI / 180;
|
||||
const scale = instance.properties.scale || 1;
|
||||
const ox = instance.properties.offsetX || 0;
|
||||
const oy = instance.properties.offsetY || 0;
|
||||
|
||||
return block.elements.map(el => {
|
||||
// Translate to instance origin, scale, rotate
|
||||
const lx = (el.x + Number(ox)) * scale;
|
||||
const ly = (el.y + Number(oy)) * scale;
|
||||
const rx = lx * Math.cos(rotation) - ly * Math.sin(rotation);
|
||||
const ry = lx * Math.sin(rotation) + ly * Math.cos(rotation);
|
||||
|
||||
const props = { ...el.properties };
|
||||
// Transform line endpoints
|
||||
if (props.x1 !== undefined && props.x2 !== undefined) {
|
||||
const x1 = (Number(props.x1) + Number(ox)) * scale;
|
||||
const y1 = (Number(props.y1) + Number(oy)) * scale;
|
||||
const x2 = (Number(props.x2) + Number(ox)) * scale;
|
||||
const y2 = (Number(props.y2) + Number(oy)) * scale;
|
||||
props.x1 = x1 * Math.cos(rotation) - y1 * Math.sin(rotation) + instance.x;
|
||||
props.y1 = x1 * Math.sin(rotation) + y1 * Math.cos(rotation) + instance.y;
|
||||
props.x2 = x2 * Math.cos(rotation) - y2 * Math.sin(rotation) + instance.x;
|
||||
props.y2 = x2 * Math.sin(rotation) + y2 * Math.cos(rotation) + instance.y;
|
||||
}
|
||||
|
||||
return {
|
||||
...el,
|
||||
id: `${el.id}_inst_${instance.id}`,
|
||||
x: rx + instance.x,
|
||||
y: ry + instance.y,
|
||||
width: el.width * scale,
|
||||
height: el.height * scale,
|
||||
properties: props,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Calculate bounding box of elements */
|
||||
getBoundingBox(elements: CADElement[]): { minX: number; minY: number; maxX: number; maxY: number } {
|
||||
if (elements.length === 0) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of elements) {
|
||||
const x1 = el.properties.x1 ?? el.x - el.width / 2;
|
||||
const y1 = el.properties.y1 ?? el.y - el.height / 2;
|
||||
const x2 = el.properties.x2 ?? el.x + el.width / 2;
|
||||
const y2 = el.properties.y2 ?? el.y + el.height / 2;
|
||||
minX = Math.min(minX, x1, x2);
|
||||
minY = Math.min(minY, y1, y2);
|
||||
maxX = Math.max(maxX, x1, x2);
|
||||
maxY = Math.max(maxY, y1, y2);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/** Import SVG as block definition (parses basic shapes) */
|
||||
importSVG(svgContent: string, name: string, category: string): BlockDefinition {
|
||||
const elements: CADElement[] = [];
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svgContent, 'image/svg+xml');
|
||||
const lines = doc.querySelectorAll('line');
|
||||
lines.forEach((line, i) => {
|
||||
const x1 = parseFloat(line.getAttribute('x1') || '0');
|
||||
const y1 = parseFloat(line.getAttribute('y1') || '0');
|
||||
const x2 = parseFloat(line.getAttribute('x2') || '0');
|
||||
const y2 = parseFloat(line.getAttribute('y2') || '0');
|
||||
elements.push({
|
||||
id: `svg_line_${i}`,
|
||||
type: 'line',
|
||||
layerId: '',
|
||||
x: (x1 + x2) / 2,
|
||||
y: (y1 + y2) / 2,
|
||||
width: Math.abs(x2 - x1),
|
||||
height: Math.abs(y2 - y1),
|
||||
properties: { x1, y1, x2, y2 },
|
||||
});
|
||||
});
|
||||
const rects = doc.querySelectorAll('rect');
|
||||
rects.forEach((rect, i) => {
|
||||
const x = parseFloat(rect.getAttribute('x') || '0');
|
||||
const y = parseFloat(rect.getAttribute('y') || '0');
|
||||
const w = parseFloat(rect.getAttribute('width') || '0');
|
||||
const h = parseFloat(rect.getAttribute('height') || '0');
|
||||
elements.push({
|
||||
id: `svg_rect_${i}`,
|
||||
type: 'rect',
|
||||
layerId: '',
|
||||
x: x + w / 2,
|
||||
y: y + h / 2,
|
||||
width: w,
|
||||
height: h,
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
const circles = doc.querySelectorAll('circle');
|
||||
circles.forEach((circle, i) => {
|
||||
const cx = parseFloat(circle.getAttribute('cx') || '0');
|
||||
const cy = parseFloat(circle.getAttribute('cy') || '0');
|
||||
const r = parseFloat(circle.getAttribute('r') || '0');
|
||||
elements.push({
|
||||
id: `svg_circle_${i}`,
|
||||
type: 'circle',
|
||||
layerId: '',
|
||||
x: cx,
|
||||
y: cy,
|
||||
width: r * 2,
|
||||
height: r * 2,
|
||||
properties: { radius: r },
|
||||
});
|
||||
});
|
||||
const blockId = `blk_svg_${Date.now()}`;
|
||||
const block: BlockDefinition = {
|
||||
id: blockId,
|
||||
name,
|
||||
description: `Imported from SVG`,
|
||||
category,
|
||||
elements,
|
||||
thumbnail: svgContent.substring(0, 200),
|
||||
};
|
||||
this.blocks.set(blockId, block);
|
||||
return block;
|
||||
}
|
||||
|
||||
/** Create a group block from selected elements */
|
||||
createGroupBlock(name: string, elements: CADElement[], category: string = 'Custom'): BlockDefinition {
|
||||
const blockId = `blk_grp_${Date.now()}`;
|
||||
const block: BlockDefinition = {
|
||||
id: blockId,
|
||||
name,
|
||||
description: 'Aus Auswahl erstellt',
|
||||
category,
|
||||
elements: elements.map(el => ({ ...el, id: `${el.id}_def` })),
|
||||
};
|
||||
this.blocks.set(blockId, block);
|
||||
return block;
|
||||
}
|
||||
}
|
||||
|
||||
/** Default block definitions with real elements */
|
||||
export function createDefaultBlocks(): BlockDefinition[] {
|
||||
return [
|
||||
{
|
||||
id: 'blk-chair',
|
||||
name: 'Stuhl-Standard',
|
||||
description: 'Standard Stuhl 0.5×0.5m',
|
||||
category: 'Bestuhlung',
|
||||
elements: [
|
||||
{ id: 'chair-seat', type: 'rect', layerId: '', x: 0, y: 0, width: 50, height: 50, properties: { fill: '#4a90d9' } },
|
||||
{ id: 'chair-back', type: 'rect', layerId: '', x: 0, y: -30, width: 50, height: 10, properties: { fill: '#357abd' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'blk-chair-vip',
|
||||
name: 'Stuhl-VIP Polster',
|
||||
description: 'VIP Polsterstuhl 0.6×0.6m',
|
||||
category: 'Bestuhlung',
|
||||
elements: [
|
||||
{ id: 'vip-seat', type: 'rect', layerId: '', x: 0, y: 0, width: 60, height: 60, properties: { fill: '#8b5cf6' } },
|
||||
{ id: 'vip-back', type: 'rect', layerId: '', x: 0, y: -35, width: 60, height: 12, properties: { fill: '#7c3aed' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'blk-table-rect',
|
||||
name: 'Bankett-Tisch 1.8×0.8',
|
||||
description: 'Rechteckiger Bankett-Tisch',
|
||||
category: 'Tische',
|
||||
elements: [
|
||||
{ id: 'table-top', type: 'rect', layerId: '', x: 0, y: 0, width: 180, height: 80, properties: { fill: '#d4a574' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'blk-table-round',
|
||||
name: 'Runder Tisch 1.5m Ø',
|
||||
description: 'Runder Tisch für 8 Personen',
|
||||
category: 'Tische',
|
||||
elements: [
|
||||
{ id: 'round-top', type: 'circle', layerId: '', x: 0, y: 0, width: 150, height: 150, properties: { radius: 75, fill: '#d4a574' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'blk-stage',
|
||||
name: 'Hauptbühne 10×3m',
|
||||
description: 'Bühnenmodul',
|
||||
category: 'Bühne',
|
||||
elements: [
|
||||
{ id: 'stage-base', type: 'rect', layerId: '', x: 0, y: 0, width: 1000, height: 300, properties: { fill: '#444' } },
|
||||
{ id: 'stage-edge', type: 'rect', layerId: '', x: 0, y: 150, width: 1000, height: 10, properties: { fill: '#666' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'blk-door',
|
||||
name: 'Tür 90°',
|
||||
description: 'Drehtür 0.9×0.9m',
|
||||
category: 'Architektur',
|
||||
elements: [
|
||||
{ id: 'door-frame', type: 'rect', layerId: '', x: 0, y: 0, width: 90, height: 90, properties: {} },
|
||||
{ id: 'door-arc', type: 'arc', layerId: '', x: 0, y: 0, width: 90, height: 90, properties: { radius: 90, startAngle: 0, endAngle: 90 } },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* CommandRegistry – Zentrale Befehls-Registry für die CAD-Command-Line.
|
||||
* F-CAD-05: Command Line (L, C, PL, R, A, T, DIM shortcuts)
|
||||
* F-UI-04: Autovervollständigung
|
||||
*/
|
||||
|
||||
export interface CommandDefinition {
|
||||
/** Primärer Befehlsname (Großbuchstaben) */
|
||||
name: string;
|
||||
/** Aliasse / Kurzformen */
|
||||
aliases: string[];
|
||||
/** Tool-ID die aktiviert wird (null für Meta-Befehle wie UNDO) */
|
||||
toolId: string | null;
|
||||
/** Beschreibung für Autovervollständigung */
|
||||
description: string;
|
||||
/** Kategorie für Gruppierung */
|
||||
category: 'draw' | 'modify' | 'view' | 'meta' | 'special';
|
||||
/** Deutsches Label für Command-History-Ausgabe */
|
||||
label: string;
|
||||
}
|
||||
|
||||
const commands: CommandDefinition[] = [
|
||||
// ─── Zeichen-Werkzeuge ─────────────────────────────
|
||||
{ name: 'LINE', aliases: ['L', 'LINIE'], toolId: 'line', description: 'Linie zeichnen', category: 'draw', label: 'Linie-Werkzeug aktiv · Klicken zum Starten' },
|
||||
{ name: 'CIRCLE', aliases: ['C', 'KREIS'], toolId: 'circle', description: 'Kreis zeichnen', category: 'draw', label: 'Kreis-Werkzeug aktiv · Klicken für Mittelpunkt' },
|
||||
{ name: 'ARC', aliases: ['A', 'BOGEN'], toolId: 'arc', description: 'Bogen zeichnen', category: 'draw', label: 'Bogen-Werkzeug aktiv · Klicken für Mittelpunkt' },
|
||||
{ name: 'RECT', aliases: ['R', 'RECTANGLE', 'RECHTECK'], toolId: 'rect', description: 'Rechteck zeichnen', category: 'draw', label: 'Rechteck-Werkzeug aktiv · Klicken für erste Ecke' },
|
||||
{ name: 'POLYLINE', aliases: ['PL', 'POLYLINIE'], toolId: 'polyline', description: 'Polylinie zeichnen', category: 'draw', label: 'Polylinie-Werkzeug aktiv · Klicken für Punkte, Doppelklick zum Beenden' },
|
||||
{ name: 'POLYGON', aliases: ['POL', 'POLYGON'], toolId: 'polygon', description: 'Polygon zeichnen', category: 'draw', label: 'Polygon-Werkzeug aktiv · Klicken für Punkte, Doppelklick zum Beenden' },
|
||||
{ name: 'TEXT', aliases: ['T', 'TXT'], toolId: 'text', description: 'Text platzieren', category: 'draw', label: 'Text-Werkzeug aktiv · Klicken zum Platzieren' },
|
||||
{ name: 'DIMENSION', aliases: ['DIM', 'BEMASSUNG'], toolId: 'dimension', description: 'Bemaßung erstellen', category: 'draw', label: 'Bemaßung-Werkzeug aktiv · Klicken für Startpunkt' },
|
||||
{ name: 'LEADER', aliases: ['LD', 'HINWEIS'], toolId: 'leader', description: 'Hinweislinie erstellen', category: 'draw', label: 'Hinweislinie · Klicken für Pfeilspitze, dann für Textposition' },
|
||||
{ name: 'REVCLOUD', aliases: ['REV', 'REVISIONSWOLKE'], toolId: 'revcloud', description: 'Revisionswolke zeichnen', category: 'draw', label: 'Revisionswolke · Klicken für Punkte, Doppelklick oder Enter zum Beenden' },
|
||||
{ name: 'HATCH', aliases: ['H', 'SCHRAFFUR'], toolId: 'hatch', description: 'Schraffur erstellen', category: 'draw', label: 'Schraffur-Werkzeug aktiv · Fläche wählen' },
|
||||
|
||||
// ─── Änderungs-Werkzeuge ───────────────────────────
|
||||
{ name: 'MOVE', aliases: ['M', 'VERSCHIEBEN'], toolId: 'move', description: 'Elemente verschieben', category: 'modify', label: 'Verschieben · Elemente auswählen, dann Basispunkt klicken' },
|
||||
{ name: 'COPY', aliases: ['CO', 'KOPIEREN'], toolId: 'copy', description: 'Elemente kopieren', category: 'modify', label: 'Kopieren · Elemente auswählen, dann Basispunkt klicken' },
|
||||
{ name: 'ROTATE', aliases: ['RO', 'ROTIEREN'], toolId: 'rotate', description: 'Elemente rotieren', category: 'modify', label: 'Rotieren · Elemente auswählen, dann Basispunkt klicken' },
|
||||
{ name: 'SCALE', aliases: ['SC', 'SKALIEREN'], toolId: 'scale', description: 'Elemente skalieren', category: 'modify', label: 'Skalieren · Elemente auswählen, dann Basispunkt klicken' },
|
||||
{ name: 'MIRROR', aliases: ['MI', 'SPIEGELN'], toolId: 'mirror', description: 'Elemente spiegeln', category: 'modify', label: 'Spiegeln · Elemente auswählen, dann Spiegellinie klicken' },
|
||||
{ name: 'TRIM', aliases: ['TR', 'TRIMMEN'], toolId: 'trim', description: 'Elemente trimmen', category: 'modify', label: 'Trimmen · Begrenzungselement klicken, dann zu trimmendes Element' },
|
||||
{ name: 'EXTEND', aliases: ['EX', 'VERLANGERN'], toolId: 'extend', description: 'Elemente verlängern', category: 'modify', label: 'Verlängern · Begrenzungselement klicken, dann zu verlängerndes Element' },
|
||||
{ name: 'FILLET', aliases: ['F', 'ABRUNDEN'], toolId: 'fillet', description: 'Elemente abrunden', category: 'modify', label: 'Abrunden · Erstes Element klicken, dann zweites Element' },
|
||||
{ name: 'OFFSET', aliases: ['O', 'VERSATZ'], toolId: 'offset', description: 'Versatz erstellen', category: 'modify', label: 'Versatz · Element klicken, dann Richtung und Abstand klicken' },
|
||||
{ name: 'ERASE', aliases: ['E', 'DEL', 'DELETE', 'LOSCHEN'], toolId: 'delete', description: 'Elemente löschen', category: 'modify', label: 'Löschen · Klicken Sie auf zu löschende Elemente' },
|
||||
|
||||
// ─── Ansicht ──────────────────────────────────────
|
||||
{ name: 'SELECT', aliases: ['V', 'AUSWAHL'], toolId: 'select', description: 'Auswahl-Werkzeug', category: 'view', label: 'Auswahl-Werkzeug aktiv' },
|
||||
{ name: 'PAN', aliases: ['P'], toolId: 'pan', description: 'Pan-Ansicht', category: 'view', label: 'Pan-Werkzeug aktiv' },
|
||||
{ name: 'ZOOM', aliases: ['Z'], toolId: 'zoom', description: 'Zoom-Ansicht', category: 'view', label: 'Zoom-Werkzeug aktiv' },
|
||||
{ name: 'GRID', aliases: ['G', 'GRID'], toolId: null, description: 'Grid ein/aus', category: 'view', label: 'Grid ein/aus' },
|
||||
{ name: 'ORTHO', aliases: ['OR'], toolId: null, description: 'Ortho-Modus ein/aus', category: 'view', label: 'Ortho-Modus ein/aus' },
|
||||
{ name: 'SNAP', aliases: ['SN'], toolId: null, description: 'Snap ein/aus', category: 'view', label: 'Snap ein/aus' },
|
||||
|
||||
// ─── Meta-Befehle ─────────────────────────────────
|
||||
{ name: 'UNDO', aliases: ['U'], toolId: null, description: 'Rückgängig', category: 'meta', label: 'Rückgängig: letzte Aktion' },
|
||||
{ name: 'REDO', aliases: ['RE'], toolId: null, description: 'Wiederherstellen', category: 'meta', label: 'Wiederherstellen: letzte Aktion' },
|
||||
{ name: 'GROUP', aliases: ['GRP'], toolId: null, description: 'Gruppe erstellen', category: 'meta', label: 'Gruppe erstellt' },
|
||||
{ name: 'UNGROUP', aliases: ['UNG'], toolId: null, description: 'Gruppe auflösen', category: 'meta', label: 'Gruppe aufgelöst' },
|
||||
{ name: 'SAVE', aliases: ['S', 'SPEICHERN'], toolId: null, description: 'Projekt speichern', category: 'meta', label: 'Projekt gespeichert' },
|
||||
{ name: 'NEW', aliases: ['N', 'NEU'], toolId: null, description: 'Neues Projekt', category: 'meta', label: 'Neues Projekt' },
|
||||
{ name: 'OPEN', aliases: ['OP', 'OFFNEN'], toolId: null, description: 'Projekt öffnen', category: 'meta', label: 'Projekt öffnen' },
|
||||
{ name: 'IMPORT', aliases: ['IMP', 'I'], toolId: null, description: 'Datei importieren (DXF, SVG, JSON)', category: 'meta', label: 'Datei importieren' },
|
||||
{ name: 'EXPORT', aliases: ['EXP', 'EX'], toolId: null, description: 'Export als DXF, SVG, PDF, PNG, JSON', category: 'meta', label: 'Export starten' },
|
||||
|
||||
// ─── Spezielle Befehle ────────────────────────────
|
||||
{ name: 'BESTUHLUNG', aliases: ['BEST', 'SEATING'], toolId: null, description: 'Bestuhlung automatisch generieren', category: 'special', label: 'Bestuhlung-Modus' },
|
||||
{ name: 'BLOCK', aliases: ['B', 'BLOCK'], toolId: null, description: 'Block erstellen', category: 'special', label: 'Block-Erstellung' },
|
||||
{ name: 'TISCH', aliases: ['TAB', 'TABLE'], toolId: null, description: 'Tisch platzieren', category: 'special', label: 'Tisch-Werkzeug' },
|
||||
{ name: 'BUHNE', aliases: ['BU', 'STAGE'], toolId: null, description: 'Bühne platzieren', category: 'special', label: 'Bühnen-Werkzeug' },
|
||||
{ name: 'KI', aliases: ['AI', 'COPilot'], toolId: null, description: 'KI Copilot öffnen', category: 'special', label: 'KI Copilot' },
|
||||
];
|
||||
|
||||
/** Alle Befehle als Map: Schlüssel = NAME + Aliasse (alle Großbuchstaben) */
|
||||
const commandMap: Map<string, CommandDefinition> = new Map();
|
||||
for (const cmd of commands) {
|
||||
commandMap.set(cmd.name, cmd);
|
||||
for (const alias of cmd.aliases) {
|
||||
commandMap.set(alias.toUpperCase(), cmd);
|
||||
}
|
||||
}
|
||||
|
||||
export class CommandRegistry {
|
||||
/** Alle Befehle zurückgeben */
|
||||
getAllCommands(): CommandDefinition[] {
|
||||
return commands;
|
||||
}
|
||||
|
||||
/** Befehl nach Name oder Alias suchen */
|
||||
lookup(input: string): CommandDefinition | null {
|
||||
const upper = input.trim().toUpperCase();
|
||||
return commandMap.get(upper) ?? null;
|
||||
}
|
||||
|
||||
/** Tool-ID für Befehl suchen */
|
||||
getToolId(input: string): string | null {
|
||||
const cmd = this.lookup(input);
|
||||
return cmd?.toolId ?? null;
|
||||
}
|
||||
|
||||
/** Label für Befehl suchen */
|
||||
getLabel(input: string): string | null {
|
||||
const cmd = this.lookup(input);
|
||||
return cmd?.label ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autovervollständigung: Sucht Befehle die mit dem Input beginnen.
|
||||
* Gibt sortierte Liste zurück (max. 10 Einträge).
|
||||
*/
|
||||
autocomplete(input: string): CommandDefinition[] {
|
||||
const upper = input.trim().toUpperCase();
|
||||
if (upper.length === 0) return [];
|
||||
const matches = new Map<string, { cmd: CommandDefinition; priority: number }>();
|
||||
for (const cmd of commands) {
|
||||
let priority = -1;
|
||||
if (cmd.name === upper) priority = 0;
|
||||
else if (cmd.aliases.some(a => a.toUpperCase() === upper)) priority = 1;
|
||||
else if (cmd.name.startsWith(upper)) priority = 2;
|
||||
else if (cmd.aliases.some(a => a.toUpperCase().startsWith(upper))) priority = 3;
|
||||
if (priority >= 0) {
|
||||
const existing = matches.get(cmd.name);
|
||||
if (!existing || priority < existing.priority) {
|
||||
matches.set(cmd.name, { cmd, priority });
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(matches.values())
|
||||
.sort((a, b) => a.priority - b.priority || a.cmd.name.localeCompare(b.cmd.name))
|
||||
.slice(0, 10)
|
||||
.map(m => m.cmd);
|
||||
}
|
||||
|
||||
/** Alle Befehlsnamen + Aliasse für Autovervollständigung */
|
||||
getAllNames(): string[] {
|
||||
const names: string[] = [];
|
||||
for (const cmd of commands) {
|
||||
names.push(cmd.name);
|
||||
names.push(...cmd.aliases);
|
||||
}
|
||||
return names.map(n => n.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton-Instanz */
|
||||
let registryInstance: CommandRegistry | null = null;
|
||||
export function getCommandRegistry(): CommandRegistry {
|
||||
if (!registryInstance) {
|
||||
registryInstance = new CommandRegistry();
|
||||
}
|
||||
return registryInstance;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { CADElement } from '../types/cad.types';
|
||||
|
||||
/**
|
||||
* Dimension & Annotation Service — creates dimension, text, leader, revcloud elements.
|
||||
* Supports linear, angular, radial dimensions and multi-line text.
|
||||
*/
|
||||
|
||||
export type DimensionType = 'linear' | 'angular' | 'radial';
|
||||
|
||||
export interface DimensionConfig {
|
||||
type: DimensionType;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
unit: 'm' | 'cm' | 'mm';
|
||||
precision: number;
|
||||
}
|
||||
|
||||
export interface TextConfig {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
rotation: number;
|
||||
multiline: boolean;
|
||||
align: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export const DEFAULT_TEXT: TextConfig = {
|
||||
text: '',
|
||||
fontSize: 14,
|
||||
rotation: 0,
|
||||
multiline: false,
|
||||
align: 'left',
|
||||
};
|
||||
|
||||
export interface LeaderConfig {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LEADER: LeaderConfig = {
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 0,
|
||||
y2: 0,
|
||||
text: '',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
export interface RevCloudConfig {
|
||||
points: Array<{ x: number; y: number }>;
|
||||
arcHeight: number;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_REVCLOUD: RevCloudConfig = {
|
||||
points: [],
|
||||
arcHeight: 8,
|
||||
fill: 'none',
|
||||
stroke: '#e0e0e0',
|
||||
};
|
||||
|
||||
export class DimensionService {
|
||||
private generateId(): string {
|
||||
return `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/** Create a linear dimension between two points */
|
||||
createLinearDimension(
|
||||
x1: number, y1: number, x2: number, y2: number,
|
||||
layerId: string, config: Partial<DimensionConfig> = {},
|
||||
): CADElement {
|
||||
const cfg = { type: 'linear' as DimensionType, x1, y1, x2, y2, offsetX: 0, offsetY: -20, unit: 'm' as const, precision: 2, ...config };
|
||||
const dist = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
||||
const mx = (x1 + x2) / 2 + cfg.offsetX;
|
||||
const my = (y1 + y2) / 2 + cfg.offsetY;
|
||||
const value = this.formatDistance(dist, cfg.unit, cfg.precision);
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'dimension',
|
||||
layerId,
|
||||
x: mx, y: my,
|
||||
width: dist, height: 20,
|
||||
properties: {
|
||||
x1: cfg.x1, y1: cfg.y1, x2: cfg.x2, y2: cfg.y2,
|
||||
offsetX: cfg.offsetX, offsetY: cfg.offsetY,
|
||||
dimType: 'linear',
|
||||
value,
|
||||
unit: cfg.unit,
|
||||
stroke: '#888',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create an angular dimension between three points (vertex, p1, p2) */
|
||||
createAngularDimension(
|
||||
vx: number, vy: number, x1: number, y1: number, x2: number, y2: number,
|
||||
layerId: string, config: Partial<DimensionConfig> = {},
|
||||
): CADElement {
|
||||
const cfg = { type: 'angular' as DimensionType, x1: vx, y1: vy, x2, y2, offsetX: 0, offsetY: -30, unit: 'deg' as any, precision: 1, ...config };
|
||||
const a1 = Math.atan2(y1 - vy, x1 - vx);
|
||||
const a2 = Math.atan2(y2 - vy, x2 - vx);
|
||||
let angle = Math.abs(a2 - a1) * 180 / Math.PI;
|
||||
if (angle > 180) angle = 360 - angle;
|
||||
const r = 30;
|
||||
const midAngle = (a1 + a2) / 2;
|
||||
const mx = vx + Math.cos(midAngle) * r;
|
||||
const my = vy + Math.sin(midAngle) * r;
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'dimension',
|
||||
layerId,
|
||||
x: mx, y: my,
|
||||
width: r * 2, height: r * 2,
|
||||
properties: {
|
||||
x1: vx, y1: vy, x2, y2,
|
||||
ax1: x1, ay1: y1, ax2: x2, ay2: y2,
|
||||
dimType: 'angular',
|
||||
value: `${angle.toFixed(cfg.precision)}°`,
|
||||
radius: r,
|
||||
stroke: '#888',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a radial dimension for a circle */
|
||||
createRadialDimension(
|
||||
cx: number, cy: number, radius: number,
|
||||
layerId: string, config: Partial<DimensionConfig> = {},
|
||||
): CADElement {
|
||||
const cfg = { type: 'radial' as DimensionType, x1: cx, y1: cy, x2: cx + radius, y2: cy, offsetX: 0, offsetY: 0, unit: 'm' as const, precision: 2, ...config };
|
||||
const value = `R ${this.formatDistance(radius, cfg.unit, cfg.precision)}`;
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'dimension',
|
||||
layerId,
|
||||
x: cx + radius / 2, y: cy - 15,
|
||||
width: radius, height: 20,
|
||||
properties: {
|
||||
x1: cx, y1: cy, x2: cx + radius, y2: cy,
|
||||
dimType: 'radial',
|
||||
value,
|
||||
unit: cfg.unit,
|
||||
stroke: '#888',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a text element (single or multi-line) */
|
||||
createText(x: number, y: number, layerId: string, config: Partial<TextConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_TEXT, ...config };
|
||||
const lines = cfg.text.split('\n');
|
||||
const height = lines.length * cfg.fontSize * 1.2;
|
||||
const width = Math.max(...lines.map(l => l.length * cfg.fontSize * 0.6), 50);
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'text',
|
||||
layerId,
|
||||
x, y,
|
||||
width, height,
|
||||
properties: {
|
||||
text: cfg.text,
|
||||
fontSize: cfg.fontSize,
|
||||
rotation: cfg.rotation,
|
||||
multiline: cfg.multiline,
|
||||
align: cfg.align,
|
||||
stroke: '#e0e0e0',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a leader element (arrow + line + text) */
|
||||
createLeader(x1: number, y1: number, x2: number, y2: number, layerId: string, config: Partial<LeaderConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_LEADER, x1, y1, x2, y2, ...config };
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'leader',
|
||||
layerId,
|
||||
x: cfg.x2, y: cfg.y2,
|
||||
width: Math.abs(cfg.x2 - cfg.x1),
|
||||
height: Math.abs(cfg.y2 - cfg.y1),
|
||||
properties: {
|
||||
x1: cfg.x1, y1: cfg.y1, x2: cfg.x2, y2: cfg.y2,
|
||||
text: cfg.text,
|
||||
fontSize: cfg.fontSize,
|
||||
stroke: '#e0e0e0',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a revision cloud from a polyline of points */
|
||||
createRevCloud(points: Array<{ x: number; y: number }>, layerId: string, config: Partial<RevCloudConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_REVCLOUD, points, ...config };
|
||||
const xs = points.map(p => p.x);
|
||||
const ys = points.map(p => p.y);
|
||||
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'revcloud',
|
||||
layerId,
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
properties: {
|
||||
points: cfg.points,
|
||||
arcHeight: cfg.arcHeight,
|
||||
fill: cfg.fill,
|
||||
stroke: cfg.stroke,
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Format a distance value with unit and precision */
|
||||
formatDistance(dist: number, unit: string, precision: number): string {
|
||||
let val = dist;
|
||||
let suffix = '';
|
||||
switch (unit) {
|
||||
case 'm': val = dist / 100; suffix = ' m'; break;
|
||||
case 'cm': val = dist / 10; suffix = ' cm'; break;
|
||||
case 'mm': val = dist; suffix = ' mm'; break;
|
||||
default: suffix = '';
|
||||
}
|
||||
return `${val.toFixed(precision)}${suffix}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* DXF Parser – converts DXF entities to CADElement[]
|
||||
* Uses dxf-parser library
|
||||
*/
|
||||
import DxfParser from 'dxf-parser';
|
||||
import type { CADElement, CADLayer, CADProperties, ElementType } from '../types/cad.types';
|
||||
|
||||
export interface DXFImportResult {
|
||||
elements: CADElement[];
|
||||
layers: CADLayer[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
const nextId = () => `dxf-${Date.now()}-${idCounter++}`;
|
||||
|
||||
/**
|
||||
* Parse a DXF string into CAD elements and layers.
|
||||
*/
|
||||
export function parseDXF(dxfString: string): DXFImportResult {
|
||||
const parser = new DxfParser();
|
||||
const dxf = parser.parseSync(dxfString) as any;
|
||||
if (!dxf) return { elements: [], layers: [], warnings: ['DXF parse returned null'] };
|
||||
const warnings: string[] = [];
|
||||
const layerMap = new Map<string, CADLayer>();
|
||||
const elements: CADElement[] = [];
|
||||
|
||||
// Build layers from DXF tables
|
||||
if (dxf.tables?.layer?.layers) {
|
||||
let sortOrder = 0;
|
||||
for (const [layerName, layerData] of Object.entries(dxf.tables.layer.layers) as [string, any][]) {
|
||||
const layer: CADLayer = {
|
||||
id: `dxf-layer-${layerName}`,
|
||||
name: layerName,
|
||||
visible: true,
|
||||
locked: false,
|
||||
color: dxfColorToHex(layerData.color) ?? '#ffffff',
|
||||
lineType: mapLineType(layerData.lineType),
|
||||
transparency: 0,
|
||||
sortOrder: sortOrder++,
|
||||
parentId: null,
|
||||
};
|
||||
layerMap.set(layerName, layer);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a default layer exists
|
||||
if (layerMap.size === 0) {
|
||||
layerMap.set('0', {
|
||||
id: 'dxf-layer-0', name: '0', visible: true, locked: false,
|
||||
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse entities
|
||||
if (dxf.entities) {
|
||||
for (const entity of dxf.entities) {
|
||||
const el = entityToCADElement(entity, layerMap);
|
||||
if (el) {
|
||||
elements.push(el);
|
||||
} else {
|
||||
warnings.push(`Unsupported entity type: ${entity.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { elements, layers: Array.from(layerMap.values()), warnings };
|
||||
}
|
||||
|
||||
function entityToCADElement(
|
||||
entity: any,
|
||||
layerMap: Map<string, CADLayer>,
|
||||
): CADElement | null {
|
||||
const layerName = entity.layer || '0';
|
||||
const layer = layerMap.get(layerName) ?? layerMap.get('0')!;
|
||||
const color = entity.color ? (dxfColorToHex(entity.color) ?? layer.color) : layer.color;
|
||||
const baseProps: CADProperties = {
|
||||
stroke: color,
|
||||
strokeWidth: 1,
|
||||
};
|
||||
|
||||
switch (entity.type) {
|
||||
case 'LINE': {
|
||||
const s = entity.vertices?.[0];
|
||||
const e = entity.vertices?.[1];
|
||||
if (!s || !e) return null;
|
||||
const minX = Math.min(s.x, e.x);
|
||||
const minY = Math.min(s.y, e.y);
|
||||
const maxX = Math.max(s.x, e.x);
|
||||
const maxY = Math.max(s.y, e.y);
|
||||
return {
|
||||
id: nextId(), type: 'line', layerId: layer.id,
|
||||
x: minX, y: minY, width: maxX - minX, height: maxY - minY,
|
||||
properties: { ...baseProps, x1: s.x, y1: s.y, x2: e.x, y2: e.y },
|
||||
};
|
||||
}
|
||||
case 'CIRCLE': {
|
||||
const c = entity.center;
|
||||
const r = entity.radius;
|
||||
if (!c || r == null) return null;
|
||||
return {
|
||||
id: nextId(), type: 'circle', layerId: layer.id,
|
||||
x: c.x - r, y: c.y - r, width: r * 2, height: r * 2,
|
||||
properties: { ...baseProps, radius: r },
|
||||
};
|
||||
}
|
||||
case 'ARC': {
|
||||
const c = entity.center;
|
||||
const r = entity.radius;
|
||||
if (!c || r == null) return null;
|
||||
return {
|
||||
id: nextId(), type: 'arc', layerId: layer.id,
|
||||
x: c.x - r, y: c.y - r, width: r * 2, height: r * 2,
|
||||
properties: { ...baseProps, radius: r, startAngle: entity.startAngle, endAngle: entity.endAngle },
|
||||
};
|
||||
}
|
||||
case 'LWPOLYLINE':
|
||||
case 'POLYLINE': {
|
||||
const pts = (entity.vertices || []).map((v: any) => ({ x: v.x, y: v.y }));
|
||||
if (pts.length < 2) return null;
|
||||
const minX = Math.min(...pts.map((p: any) => p.x));
|
||||
const minY = Math.min(...pts.map((p: any) => p.y));
|
||||
const maxX = Math.max(...pts.map((p: any) => p.x));
|
||||
const maxY = Math.max(...pts.map((p: any) => p.y));
|
||||
const isClosed = entity.shape === true;
|
||||
const type: ElementType = isClosed ? 'polygon' : 'polyline';
|
||||
return {
|
||||
id: nextId(), type, layerId: layer.id,
|
||||
x: minX, y: minY, width: maxX - minX, height: maxY - minY,
|
||||
properties: { ...baseProps, points: pts },
|
||||
};
|
||||
}
|
||||
case 'TEXT': {
|
||||
const s = entity.startPoint;
|
||||
if (!s) return null;
|
||||
return {
|
||||
id: nextId(), type: 'text', layerId: layer.id,
|
||||
x: s.x, y: s.y, width: 0, height: 0,
|
||||
properties: { ...baseProps, text: entity.text || '', fontSize: entity.height || 12 },
|
||||
};
|
||||
}
|
||||
case 'INSERT': {
|
||||
// Block reference — store as block_instance
|
||||
const pos = entity.position;
|
||||
if (!pos) return null;
|
||||
return {
|
||||
id: nextId(), type: 'block_instance', layerId: layer.id,
|
||||
x: pos.x, y: pos.y, width: 0, height: 0,
|
||||
properties: { ...baseProps, blockId: entity.name, scale: entity.scale || 1, rotation: entity.rotation || 0 },
|
||||
};
|
||||
}
|
||||
case 'DIMENSION': {
|
||||
// Basic dimension support
|
||||
const pts = entity.definitionPoint || entity.points;
|
||||
if (!pts) return null;
|
||||
return {
|
||||
id: nextId(), type: 'dimension', layerId: layer.id,
|
||||
x: 0, y: 0, width: 0, height: 0,
|
||||
properties: { ...baseProps, points: Array.isArray(pts) ? pts.map((p: any) => ({ x: p.x, y: p.y })) : [] },
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DXF ACI color to hex string
|
||||
*/
|
||||
function dxfColorToHex(aci: number | undefined): string | null {
|
||||
if (aci == null) return null;
|
||||
const colors: Record<number, string> = {
|
||||
0: '#ffffff', 1: '#ff0000', 2: '#ffff00', 3: '#00ff00',
|
||||
4: '#00ffff', 5: '#0000ff', 6: '#ff00ff', 7: '#ffffff',
|
||||
8: '#808080', 9: '#c0c0c0',
|
||||
};
|
||||
return colors[aci] ?? null;
|
||||
}
|
||||
|
||||
function mapLineType(lt: string | undefined): 'solid' | 'dashed' | 'dotted' {
|
||||
if (!lt) return 'solid';
|
||||
const u = lt.toUpperCase();
|
||||
if (u.includes('DASH')) return 'dashed';
|
||||
if (u.includes('DOT')) return 'dotted';
|
||||
return 'solid';
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* DXF Writer – converts CADElement[] to DXF string
|
||||
* Minimal DXF R12 format for compatibility
|
||||
*/
|
||||
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
||||
|
||||
/**
|
||||
* Generate a DXF R12 string from project data.
|
||||
*/
|
||||
export function writeDXF(data: ProjectData): string {
|
||||
const lines: string[] = [];
|
||||
const w = (code: number, value: string | number) => {
|
||||
lines.push(String(code));
|
||||
lines.push(String(value));
|
||||
};
|
||||
|
||||
// Header
|
||||
w(0, 'SECTION');
|
||||
w(2, 'HEADER');
|
||||
w(9, '$ACADVER'); w(1, 'AC1009'); // R12
|
||||
w(9, '$INSBASE'); w(10, 0); w(20, 0); w(30, 0);
|
||||
w(9, '$EXTMIN'); w(10, 0); w(20, 0);
|
||||
w(9, '$EXTMAX'); w(10, 1000); w(20, 1000);
|
||||
w(0, 'ENDSEC');
|
||||
|
||||
// Tables section
|
||||
w(0, 'SECTION');
|
||||
w(2, 'TABLES');
|
||||
|
||||
// Layer table
|
||||
w(0, 'TABLE');
|
||||
w(2, 'LAYER');
|
||||
w(70, data.layers.length);
|
||||
for (const layer of data.layers) {
|
||||
w(0, 'LAYER');
|
||||
w(2, layer.name);
|
||||
w(70, 0);
|
||||
w(62, hexToACI(layer.color));
|
||||
w(6, lineTypeToDXF(layer.lineType));
|
||||
}
|
||||
w(0, 'ENDTAB');
|
||||
w(0, 'ENDSEC');
|
||||
|
||||
// Entities section
|
||||
w(0, 'SECTION');
|
||||
w(2, 'ENTITIES');
|
||||
|
||||
for (const el of data.elements) {
|
||||
const layerName = getLayerName(data.layers, el.layerId);
|
||||
writeEntity(w, el, layerName);
|
||||
}
|
||||
|
||||
w(0, 'ENDSEC');
|
||||
|
||||
// EOF
|
||||
w(0, 'EOF');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function writeEntity(
|
||||
w: (code: number, value: string | number) => void,
|
||||
el: CADElement,
|
||||
layerName: string,
|
||||
): void {
|
||||
const p = el.properties;
|
||||
const stroke = (p.stroke as string) || '#ffffff';
|
||||
const aci = hexToACI(stroke);
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
w(0, 'LINE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(10, p.x1 ?? el.x); w(20, p.y1 ?? el.y); w(30, 0);
|
||||
w(11, p.x2 ?? el.x + el.width); w(21, p.y2 ?? el.y + el.height); w(31, 0);
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const cx = el.x + el.width / 2;
|
||||
const cy = el.y + el.height / 2;
|
||||
const r = p.radius ?? el.width / 2;
|
||||
w(0, 'CIRCLE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(10, cx); w(20, cy); w(30, 0);
|
||||
w(40, r);
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
const cx = el.x + el.width / 2;
|
||||
const cy = el.y + el.height / 2;
|
||||
const r = p.radius ?? el.width / 2;
|
||||
w(0, 'ARC');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(10, cx); w(20, cy); w(30, 0);
|
||||
w(40, r);
|
||||
w(50, radToDeg(p.startAngle ?? 0));
|
||||
w(51, radToDeg(p.endAngle ?? 360));
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
// Rectangle as LWPOLYLINE
|
||||
w(0, 'LWPOLYLINE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(90, 4);
|
||||
w(70, 1); // closed
|
||||
w(10, el.x); w(20, el.y);
|
||||
w(10, el.x + el.width); w(20, el.y);
|
||||
w(10, el.x + el.width); w(20, el.y + el.height);
|
||||
w(10, el.x); w(20, el.y + el.height);
|
||||
break;
|
||||
}
|
||||
case 'polygon':
|
||||
case 'polyline': {
|
||||
const pts = p.points ?? [];
|
||||
w(0, 'LWPOLYLINE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(90, pts.length);
|
||||
w(70, el.type === 'polygon' ? 1 : 0);
|
||||
for (const pt of pts) {
|
||||
w(10, pt.x); w(20, pt.y);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
w(0, 'TEXT');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(10, el.x); w(20, el.y); w(30, 0);
|
||||
w(40, p.fontSize ?? 12);
|
||||
w(1, p.text ?? '');
|
||||
break;
|
||||
}
|
||||
case 'dimension': {
|
||||
const pts = p.points ?? [];
|
||||
if (pts.length >= 2) {
|
||||
// Draw dimension as LINE + TEXT
|
||||
w(0, 'LINE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(10, pts[0].x); w(20, pts[0].y); w(30, 0);
|
||||
w(11, pts[1].x); w(21, pts[1].y); w(31, 0);
|
||||
const midX = (pts[0].x + pts[1].x) / 2;
|
||||
const midY = (pts[0].y + pts[1].y) / 2;
|
||||
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
|
||||
w(0, 'TEXT');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(10, midX); w(20, midY); w(30, 0);
|
||||
w(40, 12);
|
||||
w(1, dist.toFixed(2));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'block_instance': {
|
||||
w(0, 'INSERT');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(2, p.blockId ?? 'BLOCK');
|
||||
w(10, el.x); w(20, el.y); w(30, 0);
|
||||
w(41, p.scale ?? 1);
|
||||
w(42, p.scale ?? 1);
|
||||
w(50, radToDeg(p.rotation ?? 0));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// For chair, seating-row, etc. — export as LWPOLYLINE bounding box
|
||||
w(0, 'LWPOLYLINE');
|
||||
w(8, layerName);
|
||||
w(62, aci);
|
||||
w(90, 4);
|
||||
w(70, 1);
|
||||
w(10, el.x); w(20, el.y);
|
||||
w(10, el.x + el.width); w(20, el.y);
|
||||
w(10, el.x + el.width); w(20, el.y + el.height);
|
||||
w(10, el.x); w(20, el.y + el.height);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function getLayerName(layers: CADLayer[], layerId: string): string {
|
||||
return layers.find(l => l.id === layerId)?.name ?? '0';
|
||||
}
|
||||
|
||||
function hexToACI(hex: string): number {
|
||||
const h = hex.replace('#', '');
|
||||
const r = parseInt(h.substring(0, 2), 16);
|
||||
const g = parseInt(h.substring(2, 4), 16);
|
||||
const b = parseInt(h.substring(4, 6), 16);
|
||||
// Map common colors to ACI
|
||||
if (r > 200 && g < 100 && b < 100) return 1; // red
|
||||
if (r > 200 && g > 200 && b < 100) return 2; // yellow
|
||||
if (r < 100 && g > 200 && b < 100) return 3; // green
|
||||
if (r < 100 && g > 200 && b > 200) return 4; // cyan
|
||||
if (r < 100 && g < 100 && b > 200) return 5; // blue
|
||||
if (r > 200 && g < 100 && b > 200) return 6; // magenta
|
||||
if (r > 200 && g > 200 && b > 200) return 7; // white
|
||||
if (r < 100 && g < 100 && b < 100) return 8; // gray
|
||||
return 7; // default white
|
||||
}
|
||||
|
||||
function lineTypeToDXF(lt: string): string {
|
||||
if (lt === 'dashed') return 'DASHED';
|
||||
if (lt === 'dotted') return 'DOT';
|
||||
return 'CONTINUOUS';
|
||||
}
|
||||
|
||||
function radToDeg(rad: number): number {
|
||||
return (rad * 180) / Math.PI;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Export Service – handles DXF, SVG, PDF, PNG, JSON exports
|
||||
*/
|
||||
import { writeDXF } from './dxfWriter';
|
||||
import { exportPDF } from './pdfExport';
|
||||
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
||||
|
||||
export type ExportFormat = 'dxf' | 'svg' | 'pdf' | 'png' | 'json';
|
||||
|
||||
export interface ExportOptions {
|
||||
format: ExportFormat;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface ExportFileResult {
|
||||
success: boolean;
|
||||
blob: Blob | null;
|
||||
filename: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export project data to the specified format and trigger download.
|
||||
*/
|
||||
export async function exportProject(
|
||||
data: ProjectData,
|
||||
options: ExportOptions,
|
||||
): Promise<ExportFileResult> {
|
||||
const filename = options.filename || `${data.name || 'cad-export'}.${options.format}`;
|
||||
|
||||
try {
|
||||
switch (options.format) {
|
||||
case 'json':
|
||||
return exportJSON(data, filename);
|
||||
case 'dxf':
|
||||
return exportDXF(data, filename);
|
||||
case 'svg':
|
||||
return exportSVG(data, filename);
|
||||
case 'pdf':
|
||||
return await exportPDFFile(data, filename);
|
||||
case 'png':
|
||||
return await exportPNG(data, filename);
|
||||
default:
|
||||
return { success: false, blob: null, filename, error: `Unsupported format: ${options.format}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false, blob: null, filename,
|
||||
error: `Export error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as JSON (full project data).
|
||||
*/
|
||||
function exportJSON(data: ProjectData, filename: string): ExportFileResult {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return { success: true, blob, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as DXF.
|
||||
*/
|
||||
function exportDXF(data: ProjectData, filename: string): ExportFileResult {
|
||||
const dxf = writeDXF(data);
|
||||
const blob = new Blob([dxf], { type: 'application/dxf' });
|
||||
return { success: true, blob, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as SVG.
|
||||
*/
|
||||
function exportSVG(data: ProjectData, filename: string): ExportFileResult {
|
||||
const svg = generateSVG(data);
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
return { success: true, blob, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as PDF.
|
||||
*/
|
||||
async function exportPDFFile(data: ProjectData, filename: string): Promise<ExportFileResult> {
|
||||
const bytes = await exportPDF(data);
|
||||
const blob = new Blob([bytes as BlobPart], { type: 'application/pdf' });
|
||||
return { success: true, blob, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as PNG — renders canvas to image.
|
||||
* Requires a canvas element reference.
|
||||
*/
|
||||
async function exportPNG(data: ProjectData, filename: string): Promise<ExportFileResult> {
|
||||
// Create an offscreen canvas and render elements
|
||||
const canvas = document.createElement('canvas');
|
||||
const bbox = calculateBoundingBox(data.elements);
|
||||
const padding = 20;
|
||||
const w = (bbox.maxX - bbox.minX) + padding * 2;
|
||||
const h = (bbox.maxY - bbox.minY) + padding * 2;
|
||||
canvas.width = Math.max(w, 800);
|
||||
canvas.height = Math.max(h, 600);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return { success: false, blob: null, filename, error: 'Canvas context unavailable' };
|
||||
|
||||
// White background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Translate to content origin
|
||||
ctx.save();
|
||||
ctx.translate(-bbox.minX + padding, -bbox.minY + padding);
|
||||
|
||||
// Draw elements
|
||||
for (const el of data.elements) {
|
||||
const layer = data.layers.find(l => l.id === el.layerId);
|
||||
if (layer && !layer.visible) continue;
|
||||
drawElementToCanvas(ctx, el, layer?.color);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
const blob = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((b) => resolve(b!), 'image/png');
|
||||
});
|
||||
return { success: true, blob, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SVG string from project data.
|
||||
*/
|
||||
function generateSVG(data: ProjectData): string {
|
||||
const bbox = calculateBoundingBox(data.elements);
|
||||
const padding = 20;
|
||||
const minX = bbox.minX - padding;
|
||||
const minY = bbox.minY - padding;
|
||||
const w = (bbox.maxX - bbox.minX) + padding * 2;
|
||||
const h = (bbox.maxY - bbox.minY) + padding * 2;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${w} ${h}" width="${w}" height="${h}">`);
|
||||
|
||||
for (const el of data.elements) {
|
||||
const layer = data.layers.find(l => l.id === el.layerId);
|
||||
if (layer && !layer.visible) continue;
|
||||
const svgEl = elementToSVG(el, layer?.color);
|
||||
if (svgEl) lines.push(svgEl);
|
||||
}
|
||||
|
||||
lines.push(`</svg>`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function elementToSVG(el: CADElement, layerColor?: string): string | null {
|
||||
const p = el.properties;
|
||||
const stroke = (p.stroke as string) || layerColor || '#000000';
|
||||
const sw = p.strokeWidth ?? 1;
|
||||
const fill = p.fill as string || 'none';
|
||||
const style = `stroke="${stroke}" stroke-width="${sw}" fill="${fill}"`;
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = p.x1 ?? el.x;
|
||||
const y1 = p.y1 ?? el.y;
|
||||
const x2 = p.x2 ?? el.x + el.width;
|
||||
const y2 = p.y2 ?? el.y + el.height;
|
||||
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" ${style}/>`;
|
||||
}
|
||||
case 'circle': {
|
||||
const cx = el.x + el.width / 2;
|
||||
const cy = el.y + el.height / 2;
|
||||
const r = p.radius ?? el.width / 2;
|
||||
return `<circle cx="${cx}" cy="${cy}" r="${r}" ${style}/>`;
|
||||
}
|
||||
case 'arc': {
|
||||
const cx = el.x + el.width / 2;
|
||||
const cy = el.y + el.height / 2;
|
||||
const r = p.radius ?? el.width / 2;
|
||||
const start = p.startAngle ?? 0;
|
||||
const end = p.endAngle ?? Math.PI * 2;
|
||||
const x1 = cx + Math.cos(start) * r;
|
||||
const y1 = cy + Math.sin(start) * r;
|
||||
const x2 = cx + Math.cos(end) * r;
|
||||
const y2 = cy + Math.sin(end) * r;
|
||||
const largeArc = (end - start) > Math.PI ? 1 : 0;
|
||||
return `<path d="M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}" ${style}/>`;
|
||||
}
|
||||
case 'rect': {
|
||||
return `<rect x="${el.x}" y="${el.y}" width="${el.width}" height="${el.height}" ${style}/>`;
|
||||
}
|
||||
case 'polygon':
|
||||
case 'polyline': {
|
||||
const pts = (p.points ?? []).map(pt => `${pt.x},${pt.y}`).join(' ');
|
||||
const tag = el.type === 'polygon' ? 'polygon' : 'polyline';
|
||||
return `<${tag} points="${pts}" ${style}/>`;
|
||||
}
|
||||
case 'text': {
|
||||
const size = p.fontSize ?? 12;
|
||||
return `<text x="${el.x}" y="${el.y}" font-size="${size}" fill="${stroke}" font-family="sans-serif">${escapeXml(p.text ?? '')}</text>`;
|
||||
}
|
||||
case 'dimension': {
|
||||
const pts = p.points ?? [];
|
||||
if (pts.length < 2) return null;
|
||||
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
|
||||
const midX = (pts[0].x + pts[1].x) / 2;
|
||||
const midY = (pts[0].y + pts[1].y) / 2;
|
||||
return `<line x1="${pts[0].x}" y1="${pts[0].y}" x2="${pts[1].x}" y2="${pts[1].y}" ${style}/>\n<text x="${midX}" y="${midY}" font-size="10" fill="${stroke}">${dist.toFixed(2)}</text>`;
|
||||
}
|
||||
default: {
|
||||
return `<rect x="${el.x}" y="${el.y}" width="${el.width}" height="${el.height}" ${style}/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawElementToCanvas(ctx: CanvasRenderingContext2D, el: CADElement, layerColor?: string): void {
|
||||
const p = el.properties;
|
||||
const stroke = (p.stroke as string) || layerColor || '#000000';
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = p.strokeWidth ?? 1;
|
||||
ctx.fillStyle = (p.fill as string) || 'transparent';
|
||||
|
||||
switch (el.type) {
|
||||
case 'line':
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x1 ?? el.x, p.y1 ?? el.y);
|
||||
ctx.lineTo(p.x2 ?? el.x + el.width, p.y2 ?? el.y + el.height);
|
||||
ctx.stroke();
|
||||
break;
|
||||
case 'circle':
|
||||
ctx.beginPath();
|
||||
ctx.arc(el.x + el.width / 2, el.y + el.height / 2, p.radius ?? el.width / 2, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
case 'rect':
|
||||
ctx.strokeRect(el.x, el.y, el.width, el.height);
|
||||
break;
|
||||
case 'polygon':
|
||||
case 'polyline': {
|
||||
const pts = p.points ?? [];
|
||||
if (pts.length < 2) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0].x, pts[0].y);
|
||||
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
||||
if (el.type === 'polygon') ctx.closePath();
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'text':
|
||||
ctx.font = `${p.fontSize ?? 12}px sans-serif`;
|
||||
ctx.fillStyle = stroke;
|
||||
ctx.fillText(p.text ?? '', el.x, el.y);
|
||||
break;
|
||||
default:
|
||||
ctx.strokeRect(el.x, el.y, el.width, el.height);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function calculateBoundingBox(elements: CADElement[]) {
|
||||
if (elements.length === 0) return { minX: 0, minY: 0, maxX: 1000, maxY: 1000 };
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of elements) {
|
||||
minX = Math.min(minX, el.x);
|
||||
minY = Math.min(minY, el.y);
|
||||
maxX = Math.max(maxX, el.x + el.width);
|
||||
maxY = Math.max(maxY, el.y + el.height);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download from a blob.
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Import Service – handles DXF, SVG, PDF, JSON imports
|
||||
*/
|
||||
import { parseDXF, type DXFImportResult } from './dxfParser';
|
||||
import type { CADElement, CADLayer, BlockDefinition, ProjectData } from '../types/cad.types';
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean;
|
||||
elements: CADElement[];
|
||||
layers?: CADLayer[];
|
||||
blocks?: BlockDefinition[];
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
const nextId = () => `imp-${Date.now()}-${idCounter++}`;
|
||||
|
||||
/**
|
||||
* Import a file based on its extension.
|
||||
*/
|
||||
export async function importFile(file: File): Promise<ImportResult> {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
const text = await file.text();
|
||||
|
||||
switch (ext) {
|
||||
case 'dxf':
|
||||
return importDXF(text);
|
||||
case 'svg':
|
||||
return importSVG(text);
|
||||
case 'json':
|
||||
return importJSON(text);
|
||||
case 'pdf':
|
||||
return { success: false, elements: [], warnings: ['PDF import not yet supported'] };
|
||||
default:
|
||||
return { success: false, elements: [], warnings: [`Unsupported format: ${ext}`] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import DXF string.
|
||||
*/
|
||||
export function importDXF(dxfString: string): ImportResult {
|
||||
try {
|
||||
const result: DXFImportResult = parseDXF(dxfString);
|
||||
return {
|
||||
success: true,
|
||||
elements: result.elements,
|
||||
layers: result.layers,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
elements: [],
|
||||
warnings: [],
|
||||
error: `DXF parse error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import SVG string — converts SVG elements to CAD elements.
|
||||
*/
|
||||
export function importSVG(svgString: string): ImportResult {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svgString, 'image/svg+xml');
|
||||
const svg = doc.documentElement;
|
||||
const elements: CADElement[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Parse SVG viewBox for coordinate mapping
|
||||
const viewBox = svg.getAttribute('viewBox');
|
||||
let vbX = 0, vbY = 0;
|
||||
if (viewBox) {
|
||||
const parts = viewBox.split(/[\s,]+/).map(Number);
|
||||
vbX = parts[0] || 0;
|
||||
vbY = parts[1] || 0;
|
||||
}
|
||||
|
||||
// Process SVG elements
|
||||
const processElement = (node: Element) => {
|
||||
const tag = node.tagName.toLowerCase();
|
||||
const stroke = node.getAttribute('stroke') || '#ffffff';
|
||||
const strokeWidth = parseFloat(node.getAttribute('stroke-width') || '1');
|
||||
const fill = node.getAttribute('fill') || 'none';
|
||||
|
||||
switch (tag) {
|
||||
case 'line': {
|
||||
const x1 = parseFloat(node.getAttribute('x1') || '0') - vbX;
|
||||
const y1 = parseFloat(node.getAttribute('y1') || '0') - vbY;
|
||||
const x2 = parseFloat(node.getAttribute('x2') || '0') - vbX;
|
||||
const y2 = parseFloat(node.getAttribute('y2') || '0') - vbY;
|
||||
elements.push({
|
||||
id: nextId(), type: 'line', layerId: 'layer-0',
|
||||
x: Math.min(x1, x2), y: Math.min(y1, y2),
|
||||
width: Math.abs(x2 - x1), height: Math.abs(y2 - y1),
|
||||
properties: { stroke, strokeWidth, x1, y1, x2, y2 },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const x = parseFloat(node.getAttribute('x') || '0') - vbX;
|
||||
const y = parseFloat(node.getAttribute('y') || '0') - vbY;
|
||||
const w = parseFloat(node.getAttribute('width') || '0');
|
||||
const h = parseFloat(node.getAttribute('height') || '0');
|
||||
elements.push({
|
||||
id: nextId(), type: 'rect', layerId: 'layer-0',
|
||||
x, y, width: w, height: h,
|
||||
properties: { stroke, strokeWidth, fill: fill !== 'none' ? fill : undefined },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const cx = parseFloat(node.getAttribute('cx') || '0') - vbX;
|
||||
const cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
|
||||
const r = parseFloat(node.getAttribute('r') || '0');
|
||||
elements.push({
|
||||
id: nextId(), type: 'circle', layerId: 'layer-0',
|
||||
x: cx - r, y: cy - r, width: r * 2, height: r * 2,
|
||||
properties: { stroke, strokeWidth, radius: r },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'ellipse': {
|
||||
const cx = parseFloat(node.getAttribute('cx') || '0') - vbX;
|
||||
const cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
|
||||
const rx = parseFloat(node.getAttribute('rx') || '0');
|
||||
const ry = parseFloat(node.getAttribute('ry') || '0');
|
||||
elements.push({
|
||||
id: nextId(), type: 'circle', layerId: 'layer-0',
|
||||
x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2,
|
||||
properties: { stroke, strokeWidth, radius: Math.max(rx, ry) },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'polyline':
|
||||
case 'polygon': {
|
||||
const ptsStr = node.getAttribute('points') || '';
|
||||
const pts = ptsStr.trim().split(/[\s,]+/).reduce((acc: Array<{x:number;y:number}>, val: string, idx: number) => {
|
||||
if (idx % 2 === 0) acc.push({ x: parseFloat(val) - vbX, y: 0 });
|
||||
else acc[acc.length - 1].y = parseFloat(val) - vbY;
|
||||
return acc;
|
||||
}, []);
|
||||
if (pts.length >= 2) {
|
||||
const minX = Math.min(...pts.map(p => p.x));
|
||||
const minY = Math.min(...pts.map(p => p.y));
|
||||
const maxX = Math.max(...pts.map(p => p.x));
|
||||
const maxY = Math.max(...pts.map(p => p.y));
|
||||
elements.push({
|
||||
id: nextId(), type: tag === 'polygon' ? 'polygon' : 'polyline', layerId: 'layer-0',
|
||||
x: minX, y: minY, width: maxX - minX, height: maxY - minY,
|
||||
properties: { stroke, strokeWidth, points: pts },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const x = parseFloat(node.getAttribute('x') || '0') - vbX;
|
||||
const y = parseFloat(node.getAttribute('y') || '0') - vbY;
|
||||
const fontSize = parseFloat(node.getAttribute('font-size') || '12');
|
||||
const text = node.textContent || '';
|
||||
elements.push({
|
||||
id: nextId(), type: 'text', layerId: 'layer-0',
|
||||
x, y, width: 0, height: 0,
|
||||
properties: { stroke, strokeWidth, text, fontSize },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'g': {
|
||||
// Process group children
|
||||
Array.from(node.children).forEach(processElement);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
warnings.push(`Unsupported SVG element: ${tag}`);
|
||||
}
|
||||
};
|
||||
|
||||
Array.from(svg.children).forEach(processElement);
|
||||
|
||||
return { success: true, elements, warnings };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false, elements: [], warnings: [],
|
||||
error: `SVG parse error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import JSON project file.
|
||||
*/
|
||||
export function importJSON(jsonString: string): ImportResult {
|
||||
try {
|
||||
const data: ProjectData = JSON.parse(jsonString);
|
||||
return {
|
||||
success: true,
|
||||
elements: data.elements || [],
|
||||
layers: data.layers || [],
|
||||
blocks: data.blocks || [],
|
||||
warnings: [],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false, elements: [], warnings: [],
|
||||
error: `JSON parse error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* PDF Export – renders CAD elements to PDF using pdf-lib
|
||||
*/
|
||||
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
||||
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
||||
|
||||
/**
|
||||
* Export project data to a PDF byte array.
|
||||
*/
|
||||
export async function exportPDF(data: ProjectData): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
// Calculate bounding box of all elements
|
||||
const bbox = calculateBoundingBox(data.elements);
|
||||
const padding = 20;
|
||||
const contentW = bbox.maxX - bbox.minX + padding * 2;
|
||||
const contentH = bbox.maxY - bbox.minY + padding * 2;
|
||||
|
||||
// Use A4 landscape or fit to content (whichever is larger)
|
||||
const a4W = 842; // A4 landscape in points
|
||||
const a4H = 595;
|
||||
const pageW = Math.max(a4W, contentW);
|
||||
const pageH = Math.max(a4H, contentH);
|
||||
|
||||
const page = pdfDoc.addPage([pageW, pageH]);
|
||||
const { width: pw, height: ph } = page.getSize();
|
||||
|
||||
// Flip Y axis: PDF origin is bottom-left, CAD origin is top-left
|
||||
const flipY = (y: number) => ph - y + bbox.minY - padding;
|
||||
const offsetX = -bbox.minX + padding;
|
||||
const offsetY = bbox.minY - padding;
|
||||
|
||||
// Draw elements
|
||||
for (const el of data.elements) {
|
||||
const layer = data.layers.find(l => l.id === el.layerId);
|
||||
if (layer && !layer.visible) continue;
|
||||
drawElement(page, el, font, offsetX, offsetY, flipY, layer?.color);
|
||||
}
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
|
||||
function drawElement(
|
||||
page: any,
|
||||
el: CADElement,
|
||||
font: any,
|
||||
offX: number,
|
||||
offY: number,
|
||||
flipY: (y: number) => number,
|
||||
layerColor?: string,
|
||||
): void {
|
||||
const p = el.properties;
|
||||
const color = hexToRgb(p.stroke as string) ?? hexToRgb(layerColor ?? '#000000') ?? rgb(0, 0, 0);
|
||||
const lineWidth = p.strokeWidth ?? 1;
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = (p.x1 ?? el.x) + offX;
|
||||
const y1 = flipY((p.y1 ?? el.y) - offY);
|
||||
const x2 = (p.x2 ?? el.x + el.width) + offX;
|
||||
const y2 = flipY((p.y2 ?? el.y + el.height) - offY);
|
||||
page.drawLine({ start: { x: x1, y: y1 }, end: { x: x2, y: y2 }, thickness: lineWidth, color });
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const cx = el.x + el.width / 2 + offX;
|
||||
const cy = flipY(el.y + el.height / 2 - offY);
|
||||
const r = p.radius ?? el.width / 2;
|
||||
page.drawCircle({ x: cx, y: cy, radius: r, borderColor: color, borderWidth: lineWidth });
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
// Approximate arc with line segments
|
||||
const cx = el.x + el.width / 2 + offX;
|
||||
const cy = flipY(el.y + el.height / 2 - offY);
|
||||
const r = p.radius ?? el.width / 2;
|
||||
const start = p.startAngle ?? 0;
|
||||
const end = p.endAngle ?? Math.PI * 2;
|
||||
const segments = 32;
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const a1 = start + (end - start) * (i / segments);
|
||||
const a2 = start + (end - start) * ((i + 1) / segments);
|
||||
page.drawLine({
|
||||
start: { x: cx + Math.cos(a1) * r, y: cy + Math.sin(a1) * r },
|
||||
end: { x: cx + Math.cos(a2) * r, y: cy + Math.sin(a2) * r },
|
||||
thickness: lineWidth, color,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
page.drawRectangle({
|
||||
x: el.x + offX, y: flipY(el.y + el.height - offY),
|
||||
width: el.width, height: el.height,
|
||||
borderColor: color, borderWidth: lineWidth,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'polygon':
|
||||
case 'polyline': {
|
||||
const pts = p.points ?? [];
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
page.drawLine({
|
||||
start: { x: pts[i].x + offX, y: flipY(pts[i].y - offY) },
|
||||
end: { x: pts[i + 1].x + offX, y: flipY(pts[i + 1].y - offY) },
|
||||
thickness: lineWidth, color,
|
||||
});
|
||||
}
|
||||
if (el.type === 'polygon' && pts.length > 2) {
|
||||
page.drawLine({
|
||||
start: { x: pts[pts.length - 1].x + offX, y: flipY(pts[pts.length - 1].y - offY) },
|
||||
end: { x: pts[0].x + offX, y: flipY(pts[0].y - offY) },
|
||||
thickness: lineWidth, color,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const size = p.fontSize ?? 12;
|
||||
page.drawText(p.text ?? '', {
|
||||
x: el.x + offX, y: flipY(el.y - offY) - size,
|
||||
size, font, color,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'dimension': {
|
||||
const pts = p.points ?? [];
|
||||
if (pts.length >= 2) {
|
||||
page.drawLine({
|
||||
start: { x: pts[0].x + offX, y: flipY(pts[0].y - offY) },
|
||||
end: { x: pts[1].x + offX, y: flipY(pts[1].y - offY) },
|
||||
thickness: lineWidth, color,
|
||||
});
|
||||
const midX = (pts[0].x + pts[1].x) / 2 + offX;
|
||||
const midY = flipY((pts[0].y + pts[1].y) / 2 - offY);
|
||||
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
|
||||
page.drawText(dist.toFixed(2), { x: midX, y: midY, size: 10, font, color });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Bounding box for other element types
|
||||
page.drawRectangle({
|
||||
x: el.x + offX, y: flipY(el.y + el.height - offY),
|
||||
width: el.width, height: el.height,
|
||||
borderColor: color, borderWidth: lineWidth,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculateBoundingBox(elements: CADElement[]) {
|
||||
if (elements.length === 0) return { minX: 0, minY: 0, maxX: 1000, maxY: 1000 };
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of elements) {
|
||||
minX = Math.min(minX, el.x);
|
||||
minY = Math.min(minY, el.y);
|
||||
maxX = Math.max(maxX, el.x + el.width);
|
||||
maxY = Math.max(maxY, el.y + el.height);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string | undefined): ReturnType<typeof rgb> | null {
|
||||
if (!hex) return null;
|
||||
const h = hex.replace('#', '');
|
||||
if (h.length < 6) return null;
|
||||
const r = parseInt(h.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(h.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(h.substring(4, 6), 16) / 255;
|
||||
return rgb(r, g, b);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import type { CADElement, CADProperties } from '../types/cad.types';
|
||||
|
||||
/**
|
||||
* Seating Service — creates chairs, rows, blocks, tables, stages with configurable parameters.
|
||||
* Also provides seat counting and template presets.
|
||||
*/
|
||||
|
||||
export interface ChairConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
fill: string;
|
||||
backrestColor: string;
|
||||
outlineColor: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHAIR: ChairConfig = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
fill: '#4a90d9',
|
||||
backrestColor: '#3a7ac9',
|
||||
outlineColor: '#2a5a99',
|
||||
};
|
||||
|
||||
export interface SeatingRowConfig {
|
||||
count: number;
|
||||
spacing: number;
|
||||
rotation: number;
|
||||
chairWidth: number;
|
||||
chairHeight: number;
|
||||
fill: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROW: SeatingRowConfig = {
|
||||
count: 10,
|
||||
spacing: 50,
|
||||
rotation: 0,
|
||||
chairWidth: 40,
|
||||
chairHeight: 40,
|
||||
fill: '#4a90d9',
|
||||
};
|
||||
|
||||
export interface SeatingBlockConfig {
|
||||
rows: number;
|
||||
cols: number;
|
||||
rowSpacing: number;
|
||||
colSpacing: number;
|
||||
rowOffset: number;
|
||||
rotation: number;
|
||||
chairWidth: number;
|
||||
chairHeight: number;
|
||||
fill: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_BLOCK: SeatingBlockConfig = {
|
||||
rows: 5,
|
||||
cols: 10,
|
||||
rowSpacing: 50,
|
||||
colSpacing: 50,
|
||||
rowOffset: 0,
|
||||
rotation: 0,
|
||||
chairWidth: 40,
|
||||
chairHeight: 40,
|
||||
fill: '#4a90d9',
|
||||
};
|
||||
|
||||
export interface TableConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
shape: 'rect' | 'round';
|
||||
fill: string;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TABLE: TableConfig = {
|
||||
width: 80,
|
||||
height: 40,
|
||||
shape: 'rect',
|
||||
fill: '#8b6f47',
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
export interface StageConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
fill: string;
|
||||
rotation: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_STAGE: StageConfig = {
|
||||
width: 200,
|
||||
height: 60,
|
||||
fill: '#2c3e50',
|
||||
rotation: 0,
|
||||
label: 'Bühne',
|
||||
};
|
||||
|
||||
export interface SeatingTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'row' | 'block' | 'mixed';
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const SEATING_TEMPLATES: SeatingTemplate[] = [
|
||||
{
|
||||
name: 'Kleine Reihe',
|
||||
description: '5 Stühle in einer Reihe',
|
||||
type: 'row',
|
||||
config: { count: 5, spacing: 50, rotation: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Mittlere Reihe',
|
||||
description: '10 Stühle in einer Reihe',
|
||||
type: 'row',
|
||||
config: { count: 10, spacing: 50, rotation: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Große Reihe',
|
||||
description: '20 Stühle in einer Reihe',
|
||||
type: 'row',
|
||||
config: { count: 20, spacing: 50, rotation: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Kleiner Block',
|
||||
description: '3×5 Stühle',
|
||||
type: 'block',
|
||||
config: { rows: 3, cols: 5, rowSpacing: 50, colSpacing: 50, rowOffset: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Mittlerer Block',
|
||||
description: '5×10 Stühle',
|
||||
type: 'block',
|
||||
config: { rows: 5, cols: 10, rowSpacing: 50, colSpacing: 50, rowOffset: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Großer Block',
|
||||
description: '8×15 Stühle',
|
||||
type: 'block',
|
||||
config: { rows: 8, cols: 15, rowSpacing: 50, colSpacing: 50, rowOffset: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Konzertsaal',
|
||||
description: '3 Blöcke mit Gängen: 5×8, 5×12, 5×8',
|
||||
type: 'mixed',
|
||||
config: {
|
||||
blocks: [
|
||||
{ rows: 5, cols: 8, offsetX: 0, rowSpacing: 50, colSpacing: 50 },
|
||||
{ rows: 5, cols: 12, offsetX: 500, rowSpacing: 50, colSpacing: 50 },
|
||||
{ rows: 5, cols: 8, offsetX: 1200, rowSpacing: 50, colSpacing: 50 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Theater',
|
||||
description: '10 Reihen mit 15 Stühlen, versetzt',
|
||||
type: 'block',
|
||||
config: { rows: 10, cols: 15, rowSpacing: 55, colSpacing: 50, rowOffset: 25 },
|
||||
},
|
||||
];
|
||||
|
||||
export class SeatingService {
|
||||
private generateId(): string {
|
||||
return `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/** Create a single chair element */
|
||||
createChair(x: number, y: number, layerId: string, config: Partial<ChairConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_CHAIR, ...config };
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'chair',
|
||||
layerId,
|
||||
x,
|
||||
y,
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
properties: {
|
||||
rotation: 0,
|
||||
fill: cfg.fill,
|
||||
backrestColor: cfg.backrestColor,
|
||||
outlineColor: cfg.outlineColor,
|
||||
seatType: 'standard',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a seating row (multiple chairs in a line) */
|
||||
createSeatingRow(x: number, y: number, layerId: string, config: Partial<SeatingRowConfig> = {}): CADElement[] {
|
||||
const cfg = { ...DEFAULT_ROW, ...config };
|
||||
const elements: CADElement[] = [];
|
||||
const totalWidth = (cfg.count - 1) * cfg.spacing + cfg.chairWidth;
|
||||
const startX = x - totalWidth / 2 + cfg.chairWidth / 2;
|
||||
|
||||
for (let i = 0; i < cfg.count; i++) {
|
||||
const cx = startX + i * cfg.spacing;
|
||||
const cy = y;
|
||||
// Apply rotation around center
|
||||
const rot = cfg.rotation * Math.PI / 180;
|
||||
const dx = cx - x;
|
||||
const dy = cy - y;
|
||||
const rx = dx * Math.cos(rot) - dy * Math.sin(rot) + x;
|
||||
const ry = dx * Math.sin(rot) + dy * Math.cos(rot) + y;
|
||||
|
||||
elements.push({
|
||||
id: this.generateId(),
|
||||
type: 'chair',
|
||||
layerId,
|
||||
x: rx,
|
||||
y: ry,
|
||||
width: cfg.chairWidth,
|
||||
height: cfg.chairHeight,
|
||||
properties: {
|
||||
rotation: cfg.rotation,
|
||||
fill: cfg.fill,
|
||||
backrestColor: '#3a7ac9',
|
||||
outlineColor: '#2a5a99',
|
||||
seatType: 'standard',
|
||||
rowIndex: i,
|
||||
rowId: `row_${Date.now()}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
/** Create a seating block (rows × cols of chairs) */
|
||||
createSeatingBlock(x: number, y: number, layerId: string, config: Partial<SeatingBlockConfig> = {}): CADElement[] {
|
||||
const cfg = { ...DEFAULT_BLOCK, ...config };
|
||||
const elements: CADElement[] = [];
|
||||
const totalW = (cfg.cols - 1) * cfg.colSpacing + cfg.chairWidth;
|
||||
const totalH = (cfg.rows - 1) * cfg.rowSpacing + cfg.chairHeight;
|
||||
const startX = x - totalW / 2 + cfg.chairWidth / 2;
|
||||
const startY = y - totalH / 2 + cfg.chairHeight / 2;
|
||||
const rot = cfg.rotation * Math.PI / 180;
|
||||
const blockId = `block_${Date.now()}`;
|
||||
|
||||
for (let row = 0; row < cfg.rows; row++) {
|
||||
const offset = cfg.rowOffset * (row % 2);
|
||||
for (let col = 0; col < cfg.cols; col++) {
|
||||
const lx = startX + col * cfg.colSpacing + offset;
|
||||
const ly = startY + row * cfg.rowSpacing;
|
||||
// Rotate around center
|
||||
const dx = lx - x;
|
||||
const dy = ly - y;
|
||||
const rx = dx * Math.cos(rot) - dy * Math.sin(rot) + x;
|
||||
const ry = dx * Math.sin(rot) + dy * Math.cos(rot) + y;
|
||||
|
||||
elements.push({
|
||||
id: this.generateId(),
|
||||
type: 'chair',
|
||||
layerId,
|
||||
x: rx,
|
||||
y: ry,
|
||||
width: cfg.chairWidth,
|
||||
height: cfg.chairHeight,
|
||||
properties: {
|
||||
rotation: cfg.rotation,
|
||||
fill: cfg.fill,
|
||||
backrestColor: '#3a7ac9',
|
||||
outlineColor: '#2a5a99',
|
||||
seatType: 'standard',
|
||||
rowIndex: row,
|
||||
colIndex: col,
|
||||
blockId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
/** Create a table element */
|
||||
createTable(x: number, y: number, layerId: string, config: Partial<TableConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_TABLE, ...config };
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'table',
|
||||
layerId,
|
||||
x,
|
||||
y,
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
properties: {
|
||||
rotation: cfg.rotation,
|
||||
fill: cfg.fill,
|
||||
shape: cfg.shape,
|
||||
stroke: '#5a4a37',
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a stage element */
|
||||
createStage(x: number, y: number, layerId: string, config: Partial<StageConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_STAGE, ...config };
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'stage',
|
||||
layerId,
|
||||
x,
|
||||
y,
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
properties: {
|
||||
rotation: cfg.rotation,
|
||||
fill: cfg.fill,
|
||||
stroke: '#1a2e3f',
|
||||
strokeWidth: 2,
|
||||
label: cfg.label,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create elements from a template */
|
||||
createFromTemplate(templateName: string, x: number, y: number, layerId: string): CADElement[] {
|
||||
const template = SEATING_TEMPLATES.find(t => t.name === templateName);
|
||||
if (!template) return [];
|
||||
|
||||
if (template.type === 'row') {
|
||||
return this.createSeatingRow(x, y, layerId, template.config as Partial<SeatingRowConfig>);
|
||||
}
|
||||
if (template.type === 'block') {
|
||||
return this.createSeatingBlock(x, y, layerId, template.config as Partial<SeatingBlockConfig>);
|
||||
}
|
||||
if (template.type === 'mixed') {
|
||||
const blocks = (template.config as { blocks: Array<Record<string, number>> }).blocks;
|
||||
const elements: CADElement[] = [];
|
||||
for (const blk of blocks) {
|
||||
const bx = x + (blk.offsetX || 0);
|
||||
const cfg: Partial<SeatingBlockConfig> = {
|
||||
rows: blk.rows,
|
||||
cols: blk.cols,
|
||||
rowSpacing: blk.rowSpacing || 50,
|
||||
colSpacing: blk.colSpacing || 50,
|
||||
rowOffset: 0,
|
||||
};
|
||||
elements.push(...this.createSeatingBlock(bx, y, layerId, cfg));
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Count seats in a list of elements */
|
||||
countSeats(elements: CADElement[]): { total: number; byRow: Record<string, number>; byBlock: Record<string, number> } {
|
||||
let total = 0;
|
||||
const byRow: Record<string, number> = {};
|
||||
const byBlock: Record<string, number> = {};
|
||||
|
||||
for (const el of elements) {
|
||||
if (el.type === 'chair') {
|
||||
total++;
|
||||
const rowId = el.properties.rowId as string | undefined;
|
||||
const blockId = el.properties.blockId as string | undefined;
|
||||
if (rowId) {
|
||||
byRow[rowId] = (byRow[rowId] || 0) + 1;
|
||||
}
|
||||
if (blockId) {
|
||||
byBlock[blockId] = (byBlock[blockId] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { total, byRow, byBlock };
|
||||
}
|
||||
|
||||
/** Get all chairs belonging to a specific row */
|
||||
getRowElements(elements: CADElement[], rowId: string): CADElement[] {
|
||||
return elements.filter(el => el.type === 'chair' && el.properties.rowId === rowId);
|
||||
}
|
||||
|
||||
/** Get all chairs belonging to a specific block */
|
||||
getBlockElements(elements: CADElement[], blockId: string): CADElement[] {
|
||||
return elements.filter(el => el.type === 'chair' && el.properties.blockId === blockId);
|
||||
}
|
||||
|
||||
/** Add a chair to an existing row at the end */
|
||||
addChairToRow(elements: CADElement[], rowId: string, layerId: string): CADElement | null {
|
||||
const rowChairs = this.getRowElements(elements, rowId);
|
||||
if (rowChairs.length === 0) return null;
|
||||
const last = rowChairs[rowChairs.length - 1];
|
||||
const spacing = 50;
|
||||
const rot = (last.properties.rotation || 0) * Math.PI / 180;
|
||||
const dx = spacing;
|
||||
const dy = 0;
|
||||
const rx = dx * Math.cos(rot) - dy * Math.sin(rot) + last.x;
|
||||
const ry = dx * Math.sin(rot) + dy * Math.cos(rot) + last.y;
|
||||
return this.createChair(rx, ry, layerId, {
|
||||
width: last.width,
|
||||
height: last.height,
|
||||
fill: last.properties.fill as string,
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a chair from a row by index */
|
||||
removeChairFromRow(elements: CADElement[], rowId: string, index: number): string[] {
|
||||
const rowChairs = this.getRowElements(elements, rowId);
|
||||
if (index < 0 || index >= rowChairs.length) return [];
|
||||
const sorted = rowChairs.sort((a, b) => (a.properties.rowIndex as number) - (b.properties.rowIndex as number));
|
||||
return [sorted[index].id];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
/* Auth Pages – Login, Register, Dashboard */
|
||||
|
||||
/* ─── Auth Page Layout ─────────────────────────────── */
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary, #1a1a2e);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--bg-secondary, #16213e);
|
||||
border: 1px solid var(--border-color, #2a2a4a);
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.25rem 0;
|
||||
text-align: center;
|
||||
color: var(--accent-primary, #4a9eff);
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #888);
|
||||
text-align: center;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
/* ─── Auth Form ────────────────────────────────────── */
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.auth-field label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.auth-field input {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--border-color, #2a2a4a);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-input, #0f0f23);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.auth-field input:focus {
|
||||
border-color: var(--accent-primary, #4a9eff);
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-primary, #4a9eff);
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.auth-button:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── Auth Error ───────────────────────────────────── */
|
||||
.auth-error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 1rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Auth Switch Link ─────────────────────────────── */
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent-primary, #4a9eff);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ─── Dashboard ────────────────────────────────────── */
|
||||
.dashboard-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary, #1a1a2e);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid var(--border-color, #2a2a4a);
|
||||
background: var(--bg-secondary, #16213e);
|
||||
}
|
||||
|
||||
.dashboard-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-brand h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--accent-primary, #4a9eff);
|
||||
}
|
||||
|
||||
.dashboard-user {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.dashboard-logout {
|
||||
padding: 0.375rem 0.875rem;
|
||||
border: 1px solid var(--border-color, #2a2a4a);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.dashboard-logout:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-section-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-create-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-primary, #4a9eff);
|
||||
color: #fff;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-create-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard-create-form input {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #2a2a4a);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-input, #0f0f23);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dashboard-create-form input:focus {
|
||||
border-color: var(--accent-primary, #4a9eff);
|
||||
}
|
||||
|
||||
.dashboard-create-form button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dashboard-create-form button:first-of-type {
|
||||
background: var(--accent-primary, #4a9eff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dashboard-create-form button:last-of-type {
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #888);
|
||||
border: 1px solid var(--border-color, #2a2a4a);
|
||||
}
|
||||
|
||||
.dashboard-loading,
|
||||
.dashboard-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #888);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-project-card {
|
||||
background: var(--bg-secondary, #16213e);
|
||||
border: 1px solid var(--border-color, #2a2a4a);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.dashboard-project-card:hover {
|
||||
border-color: var(--accent-primary, #4a9eff);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.dashboard-project-card h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.dashboard-project-card p {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #888);
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.dashboard-project-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.dashboard-delete-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-delete-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Group management for CAD elements.
|
||||
* Groups are collections of element IDs that can be manipulated together.
|
||||
*/
|
||||
|
||||
export interface ElementGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
elementIds: string[];
|
||||
parentGroupId: string | null;
|
||||
}
|
||||
|
||||
export class GroupManager {
|
||||
private groups: Map<string, ElementGroup> = new Map();
|
||||
private counter = 0;
|
||||
|
||||
/**
|
||||
* Create a new group from a set of element IDs.
|
||||
*/
|
||||
createGroup(elementIds: string[], name?: string): ElementGroup {
|
||||
const id = `grp_${Date.now()}_${this.counter++}`;
|
||||
const group: ElementGroup = {
|
||||
id,
|
||||
name: name ?? `Group ${this.groups.size + 1}`,
|
||||
elementIds: [...elementIds],
|
||||
parentGroupId: null,
|
||||
};
|
||||
this.groups.set(id, group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a group (ungroup). Returns the element IDs that were in the group.
|
||||
*/
|
||||
ungroup(groupId: string): string[] {
|
||||
const group = this.groups.get(groupId);
|
||||
if (!group) return [];
|
||||
this.groups.delete(groupId);
|
||||
return group.elementIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a group by ID.
|
||||
*/
|
||||
getGroup(id: string): ElementGroup | undefined {
|
||||
return this.groups.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all groups.
|
||||
*/
|
||||
getGroups(): ElementGroup[] {
|
||||
return Array.from(this.groups.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all element IDs that belong to any group.
|
||||
*/
|
||||
getGroupedElements(): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const group of this.groups.values()) {
|
||||
for (const id of group.elementIds) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the group ID for a given element ID, if any.
|
||||
*/
|
||||
getGroupForElement(elementId: string): string | null {
|
||||
for (const [groupId, group] of this.groups) {
|
||||
if (group.elementIds.includes(elementId)) {
|
||||
return groupId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move all elements in a group by dx, dy.
|
||||
* Returns the list of element IDs that need to be modified.
|
||||
*/
|
||||
moveGroup(groupId: string, _dx: number, _dy: number): string[] {
|
||||
const group = this.groups.get(groupId);
|
||||
if (!group) return [];
|
||||
return [...group.elementIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set parent group (nesting).
|
||||
*/
|
||||
setParent(groupId: string, parentGroupId: string | null): void {
|
||||
const group = this.groups.get(groupId);
|
||||
if (!group) return;
|
||||
// Prevent circular references
|
||||
if (parentGroupId) {
|
||||
let current: string | null = parentGroupId;
|
||||
while (current) {
|
||||
if (current === groupId) return; // Would create a cycle
|
||||
const parent = this.groups.get(current);
|
||||
if (!parent) break;
|
||||
current = parent.parentGroupId;
|
||||
}
|
||||
}
|
||||
group.parentGroupId = parentGroupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all groups.
|
||||
*/
|
||||
clear(): void {
|
||||
this.groups.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize groups to JSON.
|
||||
*/
|
||||
toJSON(): ElementGroup[] {
|
||||
return this.getGroups();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore groups from JSON.
|
||||
*/
|
||||
fromJSON(groups: ElementGroup[]): void {
|
||||
this.groups.clear();
|
||||
for (const group of groups) {
|
||||
this.groups.set(group.id, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Pure geometry transformation functions for CAD elements.
|
||||
* Each function returns a NEW CADElement (immutable).
|
||||
*/
|
||||
|
||||
import type { CADElement, CADProperties } from '../../types/cad.types';
|
||||
|
||||
/**
|
||||
* Move element by dx, dy.
|
||||
*/
|
||||
export function moveElement(el: CADElement, dx: number, dy: number): CADElement {
|
||||
const props = { ...el.properties };
|
||||
|
||||
if (props.x1 !== undefined) props.x1 += dx;
|
||||
if (props.y1 !== undefined) props.y1 += dy;
|
||||
if (props.x2 !== undefined) props.x2 += dx;
|
||||
if (props.y2 !== undefined) props.y2 += dy;
|
||||
if (props.points) {
|
||||
props.points = props.points.map(p => ({ x: p.x + dx, y: p.y + dy }));
|
||||
}
|
||||
|
||||
return {
|
||||
...el,
|
||||
x: el.x + dx,
|
||||
y: el.y + dy,
|
||||
properties: props,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate element around center (cx, cy) by angle in degrees.
|
||||
*/
|
||||
export function rotateElement(el: CADElement, cx: number, cy: number, angle: number): CADElement {
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
function rot(x: number, y: number): [number, number] {
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
||||
}
|
||||
|
||||
const props = { ...el.properties };
|
||||
const [nx, ny] = rot(el.x, el.y);
|
||||
|
||||
if (props.x1 !== undefined && props.y1 !== undefined) {
|
||||
[props.x1, props.y1] = rot(props.x1, props.y1);
|
||||
}
|
||||
if (props.x2 !== undefined && props.y2 !== undefined) {
|
||||
[props.x2, props.y2] = rot(props.x2, props.y2);
|
||||
}
|
||||
if (props.points) {
|
||||
props.points = props.points.map(p => {
|
||||
const [px, py] = rot(p.x, p.y);
|
||||
return { x: px, y: py };
|
||||
});
|
||||
}
|
||||
|
||||
// Update rotation property (additive)
|
||||
props.rotation = (props.rotation ?? 0) + angle;
|
||||
|
||||
// Swap width/height for 90/270 degree rotations on rect
|
||||
let w = el.width;
|
||||
let h = el.height;
|
||||
const normAngle = ((angle % 360) + 360) % 360;
|
||||
if (normAngle === 90 || normAngle === 270) {
|
||||
[w, h] = [h, w];
|
||||
}
|
||||
|
||||
return {
|
||||
...el,
|
||||
x: nx,
|
||||
y: ny,
|
||||
width: w,
|
||||
height: h,
|
||||
properties: props,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale element around center (cx, cy) by factors sx, sy.
|
||||
*/
|
||||
export function scaleElement(el: CADElement, cx: number, cy: number, sx: number, sy: number): CADElement {
|
||||
function scl(x: number, y: number): [number, number] {
|
||||
return [cx + (x - cx) * sx, cy + (y - cy) * sy];
|
||||
}
|
||||
|
||||
const props = { ...el.properties };
|
||||
const [nx, ny] = scl(el.x, el.y);
|
||||
|
||||
if (props.x1 !== undefined && props.y1 !== undefined) {
|
||||
[props.x1, props.y1] = scl(props.x1, props.y1);
|
||||
}
|
||||
if (props.x2 !== undefined && props.y2 !== undefined) {
|
||||
[props.x2, props.y2] = scl(props.x2, props.y2);
|
||||
}
|
||||
if (props.points) {
|
||||
props.points = props.points.map(p => {
|
||||
const [px, py] = scl(p.x, p.y);
|
||||
return { x: px, y: py };
|
||||
});
|
||||
}
|
||||
if (props.radius !== undefined) {
|
||||
props.radius *= Math.max(sx, sy);
|
||||
}
|
||||
|
||||
return {
|
||||
...el,
|
||||
x: nx,
|
||||
y: ny,
|
||||
width: el.width * sx,
|
||||
height: el.height * sy,
|
||||
properties: props,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror element across a line defined by (x1,y1) and (x2,y2).
|
||||
*/
|
||||
export function mirrorElement(el: CADElement, x1: number, y1: number, x2: number, y2: number): CADElement {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq === 0) return el;
|
||||
|
||||
function mir(px: number, py: number): [number, number] {
|
||||
const t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
|
||||
const projX = x1 + t * dx;
|
||||
const projY = y1 + t * dy;
|
||||
return [2 * projX - px, 2 * projY - py];
|
||||
}
|
||||
|
||||
const props = { ...el.properties };
|
||||
const [nx, ny] = mir(el.x, el.y);
|
||||
|
||||
if (props.x1 !== undefined && props.y1 !== undefined) {
|
||||
[props.x1, props.y1] = mir(props.x1, props.y1);
|
||||
}
|
||||
if (props.x2 !== undefined && props.y2 !== undefined) {
|
||||
[props.x2, props.y2] = mir(props.x2, props.y2);
|
||||
}
|
||||
if (props.points) {
|
||||
props.points = props.points.map(p => {
|
||||
const [mx, my] = mir(p.x, p.y);
|
||||
return { x: mx, y: my };
|
||||
});
|
||||
}
|
||||
|
||||
// Flip rotation
|
||||
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
||||
props.rotation = 2 * angle - (props.rotation ?? 0);
|
||||
|
||||
return {
|
||||
...el,
|
||||
x: nx,
|
||||
y: ny,
|
||||
properties: props,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset element by a distance (creates a parallel copy).
|
||||
* For lines: offset perpendicular. For circles/arcs: adjust radius.
|
||||
*/
|
||||
export function offsetElement(el: CADElement, distance: number): CADElement {
|
||||
const props = { ...el.properties };
|
||||
|
||||
if (el.type === 'line' && props.x1 !== undefined && props.y1 !== undefined && props.x2 !== undefined && props.y2 !== undefined) {
|
||||
const dx = props.x2 - props.x1;
|
||||
const dy = props.y2 - props.y1;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len === 0) return el;
|
||||
// Perpendicular unit vector
|
||||
const nx = -dy / len;
|
||||
const ny = dx / len;
|
||||
const ox = nx * distance;
|
||||
const oy = ny * distance;
|
||||
props.x1 += ox;
|
||||
props.y1 += oy;
|
||||
props.x2 += ox;
|
||||
props.y2 += oy;
|
||||
return { ...el, x: el.x + ox, y: el.y + oy, properties: props };
|
||||
}
|
||||
|
||||
if ((el.type === 'circle' || el.type === 'arc') && props.radius !== undefined) {
|
||||
props.radius = Math.abs(props.radius + distance);
|
||||
const d = props.radius * 2;
|
||||
return { ...el, width: d, height: d, properties: props };
|
||||
}
|
||||
|
||||
if ((el.type === 'polyline' || el.type === 'polygon') && props.points) {
|
||||
// Offset each segment perpendicular — simplified: shift all points by average normal
|
||||
// For a proper offset, each vertex needs miter calculation. This is a simplified version.
|
||||
props.points = props.points.map((p, i) => {
|
||||
const prev = props.points![Math.max(0, i - 1)];
|
||||
const next = props.points![Math.min(props.points!.length - 1, i + 1)];
|
||||
const dx = next.x - prev.x;
|
||||
const dy = next.y - prev.y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len === 0) return p;
|
||||
const nx = -dy / len;
|
||||
const ny = dx / len;
|
||||
return { x: p.x + nx * distance, y: p.y + ny * distance };
|
||||
});
|
||||
return { ...el, properties: props };
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim element at boundary. Returns the trimmed element or null if no trim possible.
|
||||
* Currently supports trimming lines at a boundary point.
|
||||
*/
|
||||
export function trimElement(el: CADElement, boundary: CADElement): CADElement | null {
|
||||
// Simplified: find intersection with boundary, trim line to that point
|
||||
if (el.type !== 'line' || !el.properties.x1 || !el.properties.x2) return null;
|
||||
|
||||
const intersect = findIntersection(el, boundary);
|
||||
if (!intersect) return null;
|
||||
|
||||
// Trim from the end closest to the intersection
|
||||
const props = { ...el.properties };
|
||||
const d1 = Math.sqrt((intersect.x - props.x1!) ** 2 + (intersect.y - props.y1!) ** 2);
|
||||
const d2 = Math.sqrt((intersect.x - props.x2!) ** 2 + (intersect.y - props.y2!) ** 2);
|
||||
|
||||
if (d1 < d2) {
|
||||
props.x2 = intersect.x;
|
||||
props.y2 = intersect.y;
|
||||
} else {
|
||||
props.x1 = intersect.x;
|
||||
props.y1 = intersect.y;
|
||||
}
|
||||
|
||||
// Recalculate center and bbox
|
||||
const cx = (props.x1! + props.x2!) / 2;
|
||||
const cy = (props.y1! + props.y2!) / 2;
|
||||
const w = Math.abs(props.x2! - props.x1!);
|
||||
const h = Math.abs(props.y2! - props.y1!);
|
||||
|
||||
return { ...el, x: cx, y: cy, width: w, height: h, properties: props };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend element to meet boundary. Returns extended element or null.
|
||||
* Currently supports extending lines to a boundary intersection.
|
||||
*/
|
||||
export function extendElement(el: CADElement, boundary: CADElement): CADElement | null {
|
||||
if (el.type !== 'line' || !el.properties.x1 || !el.properties.x2) return null;
|
||||
|
||||
const intersect = findIntersection(el, boundary);
|
||||
if (!intersect) return null;
|
||||
|
||||
const props = { ...el.properties };
|
||||
// Extend the end that is closer to the intersection
|
||||
const d1 = Math.sqrt((intersect.x - props.x1!) ** 2 + (intersect.y - props.y1!) ** 2);
|
||||
const d2 = Math.sqrt((intersect.x - props.x2!) ** 2 + (intersect.y - props.y2!) ** 2);
|
||||
|
||||
if (d1 < d2) {
|
||||
props.x1 = intersect.x;
|
||||
props.y1 = intersect.y;
|
||||
} else {
|
||||
props.x2 = intersect.x;
|
||||
props.y2 = intersect.y;
|
||||
}
|
||||
|
||||
const cx = (props.x1! + props.x2!) / 2;
|
||||
const cy = (props.y1! + props.y2!) / 2;
|
||||
const w = Math.abs(props.x2! - props.x1!);
|
||||
const h = Math.abs(props.y2! - props.y1!);
|
||||
|
||||
return { ...el, x: cx, y: cy, width: w, height: h, properties: props };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fillet two elements with a given radius.
|
||||
* Currently supports filleting two lines by trimming/adjusting endpoints.
|
||||
*/
|
||||
export function filletElements(el1: CADElement, el2: CADElement, radius: number): [CADElement, CADElement] | null {
|
||||
// Simplified: find intersection of two lines, then trim both to the fillet point
|
||||
const intersect = findIntersection(el1, el2);
|
||||
if (!intersect) return null;
|
||||
|
||||
// For now, just trim both lines to the intersection point (true arc fillet is complex)
|
||||
const p1 = { ...el1.properties };
|
||||
const p2 = { ...el2.properties };
|
||||
|
||||
if (!p1.x1 || !p1.x2 || !p2.x1 || !p2.x2) return null;
|
||||
|
||||
// Determine which endpoint of each line is closest to intersection
|
||||
const trim1 = trimElement(el1, el2);
|
||||
const trim2 = trimElement(el2, el1);
|
||||
|
||||
if (!trim1 || !trim2) return null;
|
||||
|
||||
return [trim1, trim2];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find intersection point of two elements (lines only for now).
|
||||
*/
|
||||
function findIntersection(el1: CADElement, el2: CADElement): { x: number; y: number } | null {
|
||||
const p1 = el1.properties;
|
||||
const p2 = el2.properties;
|
||||
|
||||
if (p1.x1 === undefined || p1.y1 === undefined || p1.x2 === undefined || p1.y2 === undefined) return null;
|
||||
if (p2.x1 === undefined || p2.y1 === undefined || p2.x2 === undefined || p2.y2 === undefined) return null;
|
||||
|
||||
// Line-line intersection
|
||||
const denom = (p1.x1 - p1.x2) * (p2.y1 - p2.y2) - (p1.y1 - p1.y2) * (p2.x1 - p2.x2);
|
||||
if (Math.abs(denom) < 1e-10) return null;
|
||||
|
||||
const t = ((p1.x1 - p2.x1) * (p2.y1 - p2.y2) - (p1.y1 - p2.y1) * (p2.x1 - p2.x2)) / denom;
|
||||
const x = p1.x1 + t * (p1.x2 - p1.x1);
|
||||
const y = p1.y1 + t * (p1.y2 - p1.y1);
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bounding box of an element.
|
||||
*/
|
||||
export function getElementBBox(el: CADElement): { minX: number; minY: number; maxX: number; maxY: number } {
|
||||
if (el.properties.points) {
|
||||
const xs = el.properties.points.map(p => p.x);
|
||||
const ys = el.properties.points.map(p => p.y);
|
||||
return { minX: Math.min(...xs), minY: Math.min(...ys), maxX: Math.max(...xs), maxY: Math.max(...ys) };
|
||||
}
|
||||
return {
|
||||
minX: el.x - el.width / 2,
|
||||
minY: el.y - el.height / 2,
|
||||
maxX: el.x + el.width / 2,
|
||||
maxY: el.y + el.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points.
|
||||
*/
|
||||
export function distance(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
|
||||
return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate angle between two points in degrees.
|
||||
*/
|
||||
export function angleBetween(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
|
||||
return (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Core CAD type definitions — single source of truth.
|
||||
*/
|
||||
|
||||
export type ElementType =
|
||||
| 'line' | 'circle' | 'arc' | 'rect' | 'polygon'
|
||||
| 'polyline' | 'text' | 'dimension' | 'block_instance' | 'chair'
|
||||
| 'seating-row' | 'seating-block' | 'table' | 'stage'
|
||||
| 'leader' | 'revcloud';
|
||||
|
||||
export type LineType = 'solid' | 'dashed' | 'dotted';
|
||||
|
||||
export interface CADLayer {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
color: string;
|
||||
lineType: LineType;
|
||||
transparency: number;
|
||||
sortOrder: number;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export interface CADProperties {
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
rotation?: number;
|
||||
lineType?: LineType;
|
||||
radius?: number;
|
||||
x1?: number;
|
||||
y1?: number;
|
||||
x2?: number;
|
||||
y2?: number;
|
||||
points?: Array<{ x: number; y: number }>;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
startAngle?: number;
|
||||
endAngle?: number;
|
||||
blockId?: string;
|
||||
scale?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CADElement {
|
||||
id: string;
|
||||
type: ElementType;
|
||||
layerId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
properties: CADProperties;
|
||||
}
|
||||
|
||||
export interface BlockDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
elements: CADElement[];
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export interface Transform {
|
||||
a: number; b: number; c: number; d: number; e: number; f: number;
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export interface ProjectData {
|
||||
version: string;
|
||||
name: string;
|
||||
layers: CADLayer[];
|
||||
elements: CADElement[];
|
||||
blocks: BlockDefinition[];
|
||||
background?: {
|
||||
src: string;
|
||||
scale: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
rotation: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
success: boolean;
|
||||
data?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ToolType =
|
||||
| 'select' | 'pan' | 'zoom-win' | 'line' | 'polyline' | 'circle' | 'arc'
|
||||
| 'rect' | 'polygon' | 'text' | 'dimension' | 'hatch'
|
||||
| 'move' | 'copy' | 'rotate' | 'scale' | 'mirror'
|
||||
| 'trim' | 'extend' | 'fillet' | 'offset'
|
||||
| 'chair' | 'seating-row' | 'seating-block' | 'table' | 'stage'
|
||||
| 'seating-template' | 'measure' | 'delete'
|
||||
| 'leader' | 'revcloud';
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* UI-specific type definitions for React components.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import type { CADElement, CADLayer, BlockDefinition, ToolType } from './cad.types';
|
||||
import type { ToolState } from '../interaction';
|
||||
import type { BackgroundConfig } from '../services/backgroundService';
|
||||
import type { UserCursor } from '../crdt';
|
||||
|
||||
export type ViewMode = '2d' | 'iso' | 'front' | 'top';
|
||||
export type RibbonTab = 'start' | 'insert' | 'format' | 'view' | 'tools' | 'ki';
|
||||
export type RightPanel = 'tool' | 'layer' | 'library' | 'ki' | 'plugins';
|
||||
export type Theme = 'light' | 'dark';
|
||||
export type DrawerTab = 'tool' | 'layer' | 'library' | 'ki' | 'plugins';
|
||||
|
||||
export interface TreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
count?: number;
|
||||
icon?: ReactNode;
|
||||
children?: TreeNode[];
|
||||
expanded?: boolean;
|
||||
active?: boolean;
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
export interface KIMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export interface KISuggestion {
|
||||
id: string;
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CommandHistoryEntry {
|
||||
prefix: '·' | '›';
|
||||
text: ReactNode;
|
||||
type?: 'info' | 'command';
|
||||
}
|
||||
|
||||
export interface CursorPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface SelectedElementInfo {
|
||||
element: CADElement | null;
|
||||
count: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AppProps {
|
||||
// no props — App is root
|
||||
}
|
||||
|
||||
export interface TopbarProps {
|
||||
projectName: string;
|
||||
savedStatus: string;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onThemeToggle: () => void;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
export interface RibbonBarProps {
|
||||
activeTab: RibbonTab;
|
||||
onTabChange: (tab: RibbonTab) => void;
|
||||
onAction: (action: string) => void;
|
||||
}
|
||||
|
||||
export interface LeftSidebarProps {
|
||||
activeTool: string;
|
||||
onToolChange: (tool: string) => void;
|
||||
selectedTemplate?: string | null;
|
||||
onTemplateSelect?: (templateName: string | null) => void;
|
||||
}
|
||||
|
||||
export interface CanvasAreaProps {
|
||||
cursorPos: CursorPos;
|
||||
viewMode: ViewMode;
|
||||
onViewChange: (mode: ViewMode) => void;
|
||||
gridEnabled: boolean;
|
||||
orthoEnabled: boolean;
|
||||
snapEnabled: boolean;
|
||||
polarEnabled: boolean;
|
||||
activeTool: string;
|
||||
elements: CADElement[];
|
||||
layers: CADLayer[];
|
||||
onElementCreated: (el: CADElement) => void;
|
||||
onElementsDeleted: (ids: string[]) => void;
|
||||
onElementsModified: (els: CADElement[]) => void;
|
||||
onCursorMoved: (x: number, y: number) => void;
|
||||
onToolStateChanged: (state: ToolState) => void;
|
||||
onToggleGrid: () => void;
|
||||
onToggleOrtho: () => void;
|
||||
onToggleSnap: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onZoomFit: () => void;
|
||||
onTextEdit?: (el: CADElement) => void;
|
||||
onCommandTrigger?: (msg: string) => void;
|
||||
blocks?: BlockDefinition[];
|
||||
onBlockDrop?: (blockId: string, x: number, y: number) => void;
|
||||
onSelectionChange?: (selectedIds: string[]) => void;
|
||||
selectedTemplate?: string | null;
|
||||
bgConfig?: BackgroundConfig | null;
|
||||
remoteCursors?: UserCursor[];
|
||||
}
|
||||
|
||||
export interface RightSidebarProps {
|
||||
activePanel: RightPanel;
|
||||
onPanelChange: (panel: RightPanel) => void;
|
||||
selectedElement: CADElement | null;
|
||||
layers: CADLayer[];
|
||||
blocks: BlockDefinition[];
|
||||
activeLayerId?: string;
|
||||
onSelectLayer?: (id: string) => void;
|
||||
onAddLayer?: () => void;
|
||||
onToggleLayer?: (id: string) => void;
|
||||
onDeleteLayer?: (id: string) => void;
|
||||
onRenameLayer?: (id: string, name: string) => void;
|
||||
onDuplicateLayer?: (id: string) => void;
|
||||
onToggleLock?: (id: string) => void;
|
||||
onUpdateLayerColor?: (id: string, color: string) => void;
|
||||
onUpdateLayerLineType?: (id: string, lineType: 'solid' | 'dashed' | 'dotted') => void;
|
||||
onUpdateLayerTransparency?: (id: string, transparency: number) => void;
|
||||
onRenameBlock?: (id: string, name: string) => void;
|
||||
onDuplicateBlock?: (id: string) => void;
|
||||
onDeleteBlock?: (id: string) => void;
|
||||
onSvgImport?: (svg: string, name: string, category: string) => void;
|
||||
onSaveGroupAsBlock?: (name: string) => void;
|
||||
onBlockCategoryChange?: (cat: string) => void;
|
||||
onBlockSearch?: (query: string) => void;
|
||||
onDragBlock?: (blockId: string) => void;
|
||||
// KI Copilot
|
||||
kiMessages?: KIMessage[];
|
||||
kiSuggestions?: KISuggestion[];
|
||||
onKISend?: (text: string) => void;
|
||||
onKISuggestionClick?: (suggestion: KISuggestion) => void;
|
||||
kiLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface PropertiesPanelProps {
|
||||
selectedElement: CADElement | null;
|
||||
layers: CADLayer[];
|
||||
onUpdateProperty: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export interface LayerPanelProps {
|
||||
layers: CADLayer[];
|
||||
activeLayerId?: string;
|
||||
onSelectLayer: (id: string) => void;
|
||||
onAddLayer: () => void;
|
||||
onToggleLayer: (id: string) => void;
|
||||
onDeleteLayer?: (id: string) => void;
|
||||
onRenameLayer?: (id: string, name: string) => void;
|
||||
onDuplicateLayer?: (id: string) => void;
|
||||
onToggleLock?: (id: string) => void;
|
||||
onUpdateLayerColor?: (id: string, color: string) => void;
|
||||
onUpdateLayerLineType?: (id: string, lineType: 'solid' | 'dashed' | 'dotted') => void;
|
||||
onUpdateLayerTransparency?: (id: string, transparency: number) => void;
|
||||
}
|
||||
|
||||
export interface BlockLibraryProps {
|
||||
blocks: BlockDefinition[];
|
||||
category: string;
|
||||
onCategoryChange: (cat: string) => void;
|
||||
onSearch: (query: string) => void;
|
||||
onDragBlock: (blockId: string) => void;
|
||||
onRenameBlock?: (id: string, name: string) => void;
|
||||
onDuplicateBlock?: (id: string) => void;
|
||||
onDeleteBlock?: (id: string) => void;
|
||||
onSvgImport?: (svg: string, name: string, category: string) => void;
|
||||
onSaveGroupAsBlock?: (name: string) => void;
|
||||
}
|
||||
|
||||
export interface KICopilotProps {
|
||||
messages: KIMessage[];
|
||||
suggestions: KISuggestion[];
|
||||
onSend: (text: string) => void;
|
||||
onSuggestionClick: (suggestion: KISuggestion) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandLineProps {
|
||||
history: CommandHistoryEntry[];
|
||||
onCommand: (cmd: string) => void;
|
||||
}
|
||||
|
||||
export interface StatusBarProps {
|
||||
snapEnabled: boolean;
|
||||
orthoEnabled: boolean;
|
||||
polarEnabled: boolean;
|
||||
gridEnabled: boolean;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
activeLayer: string;
|
||||
activeTool: string;
|
||||
onlineCount: number;
|
||||
onToggleSnap: () => void;
|
||||
onToggleOrtho: () => void;
|
||||
onTogglePolar: () => void;
|
||||
onToggleGrid: () => void;
|
||||
seatCount?: number;
|
||||
}
|
||||
|
||||
export interface TreeViewProps {
|
||||
nodes: TreeNode[];
|
||||
onSelect: (id: string) => void;
|
||||
onToggle: (id: string) => void;
|
||||
draggable?: boolean;
|
||||
renderIcon?: (node: TreeNode) => ReactNode;
|
||||
}
|
||||
|
||||
export interface MobileDrawersProps {
|
||||
leftOpen: boolean;
|
||||
rightOpen: boolean;
|
||||
activeRightTab: DrawerTab;
|
||||
onCloseLeft: () => void;
|
||||
onCloseRight: () => void;
|
||||
onRightTabChange: (tab: DrawerTab) => void;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./src",
|
||||
"paths": { "@/*": ["."] }
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
'/ws': { target: 'ws://localhost:3001', ws: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
// web-cad v6 – Interactions (theme, tabs, drawers, mobile right-tab-bar)
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ===================== THEME TOGGLE =====================
|
||||
const themeBtn = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
if (themeBtn) {
|
||||
themeBtn.addEventListener('click', () => {
|
||||
const cur = html.getAttribute('data-theme');
|
||||
html.setAttribute('data-theme', cur === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
}
|
||||
|
||||
// ===================== RIGHTBAR (desktop) TAB SWITCHING =====================
|
||||
document.querySelectorAll('.rightbar-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const panel = tab.dataset.panel;
|
||||
document.querySelectorAll('.rightbar-tab').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); });
|
||||
document.querySelectorAll('.rightbar-panel').forEach(p => p.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
tab.setAttribute('aria-selected', 'true');
|
||||
const target = document.querySelector(`[data-panel-content="${panel}"]`);
|
||||
if (target) target.classList.add('active');
|
||||
// sync mobile drawer if open
|
||||
const dTab = document.querySelector(`[data-drawer-tab="${panel}"]`);
|
||||
if (dTab) dTab.click();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== DRAWER TAB SWITCHING (mobile) =====================
|
||||
document.querySelectorAll('.drawer-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const panel = tab.dataset.drawerTab;
|
||||
document.querySelectorAll('.drawer-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
// Re-render drawer body for this panel
|
||||
renderDrawerRight(panel);
|
||||
// sync desktop rightbar
|
||||
const rTab = document.querySelector(`.rightbar-tab[data-panel="${panel}"]`);
|
||||
if (rTab) {
|
||||
document.querySelectorAll('.rightbar-tab').forEach(t => t.classList.remove('active'));
|
||||
rTab.classList.add('active');
|
||||
document.querySelectorAll('.rightbar-panel').forEach(p => p.classList.remove('active'));
|
||||
const target = document.querySelector(`[data-panel-content="${panel}"]`);
|
||||
if (target) target.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== RIGHT-TAB-BAR (mobile vertical) =====================
|
||||
document.querySelectorAll('.right-tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const which = btn.dataset.drawer;
|
||||
document.querySelectorAll('.right-tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
// activate drawer tab + open drawer
|
||||
const dTab = document.querySelector(`[data-drawer-tab="${which}"]`);
|
||||
if (dTab) dTab.click();
|
||||
openDrawer('right');
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== DRAWER OPEN/CLOSE =====================
|
||||
const scrim = document.getElementById('scrim');
|
||||
const drawerLeft = document.getElementById('drawer-left');
|
||||
const drawerRight = document.getElementById('drawer-right');
|
||||
|
||||
function openDrawer(which) {
|
||||
const drawerLeftBody = document.getElementById('drawer-left-body');
|
||||
const drawerRightBody = document.getElementById('drawer-right-body');
|
||||
if (which === 'left' && drawerLeft) {
|
||||
drawerLeft.classList.add('open');
|
||||
drawerLeft.setAttribute('aria-hidden', 'false');
|
||||
// Use children.length instead of innerHTML.trim() because placeholder HTML comments would prevent re-render
|
||||
if (drawerLeftBody && drawerLeftBody.children.length === 0) {
|
||||
renderDrawerLeft();
|
||||
}
|
||||
}
|
||||
if (which === 'right' && drawerRight) {
|
||||
drawerRight.classList.add('open');
|
||||
drawerRight.setAttribute('aria-hidden', 'false');
|
||||
if (drawerRightBody && drawerRightBody.children.length === 0) {
|
||||
const active = document.querySelector('.drawer-tab.active');
|
||||
renderDrawerRight(active ? active.dataset.drawerTab : 'tool');
|
||||
}
|
||||
}
|
||||
if (scrim) scrim.classList.add('show');
|
||||
document.body.classList.add('drawer-open');
|
||||
}
|
||||
|
||||
function closeDrawers() {
|
||||
if (drawerLeft) { drawerLeft.classList.remove('open'); drawerLeft.setAttribute('aria-hidden', 'true'); }
|
||||
if (drawerRight) { drawerRight.classList.remove('open'); drawerRight.setAttribute('aria-hidden', 'true'); }
|
||||
if (scrim) scrim.classList.remove('show');
|
||||
document.body.classList.remove('drawer-open');
|
||||
}
|
||||
|
||||
// Hamburger opens left drawer
|
||||
const hamburger = document.getElementById('hamburger-btn');
|
||||
if (hamburger) {
|
||||
hamburger.addEventListener('click', () => openDrawer('left'));
|
||||
}
|
||||
|
||||
// Close buttons
|
||||
document.querySelectorAll('[data-close-drawer]').forEach(b => {
|
||||
b.addEventListener('click', closeDrawers);
|
||||
});
|
||||
|
||||
// Scrim click closes
|
||||
if (scrim) scrim.addEventListener('click', closeDrawers);
|
||||
|
||||
// ESC key closes
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeDrawers();
|
||||
});
|
||||
|
||||
// ===================== RENDER DRAWER LEFT (clone leftbar) =====================
|
||||
function renderDrawerLeft() {
|
||||
const src = document.querySelector('.leftbar');
|
||||
const dst = document.getElementById('drawer-left-body');
|
||||
if (!src || !dst) return;
|
||||
// Clone leftbar body (skip header which has toggle)
|
||||
const clone = src.cloneNode(true);
|
||||
// Remove the inner toggle button (drawer has its own close)
|
||||
const innerToggle = clone.querySelector('.leftbar-toggle');
|
||||
if (innerToggle) innerToggle.remove();
|
||||
// Remove header (drawer has its own header)
|
||||
const header = clone.querySelector('.leftbar-header');
|
||||
if (header) header.remove();
|
||||
// Remove footer (not needed in drawer)
|
||||
const footer = clone.querySelector('.leftbar-footer');
|
||||
if (footer) footer.remove();
|
||||
// Mark as drawer version + force display (the original .leftbar is hidden on mobile)
|
||||
clone.classList.add('drawer-content-left');
|
||||
clone.removeAttribute('id');
|
||||
clone.style.cssText = 'display: flex; flex-direction: column; flex: 1; min-height: 0; border: none; background: transparent;';
|
||||
dst.innerHTML = '';
|
||||
dst.appendChild(clone);
|
||||
// Wire up tool buttons inside drawer
|
||||
dst.querySelectorAll('.tool-btn').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tool-btn').forEach(x => x.classList.remove('active'));
|
||||
b.classList.add('active');
|
||||
// Close drawer on tool selection (mobile UX)
|
||||
setTimeout(() => closeDrawers(), 200);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===================== RENDER DRAWER RIGHT (clone rightbar panel) =====================
|
||||
function renderDrawerRight(panel) {
|
||||
const src = document.querySelector(`[data-panel-content="${panel}"]`);
|
||||
const dst = document.getElementById('drawer-right-body');
|
||||
const titleEl = document.getElementById('drawer-right-title');
|
||||
if (!src || !dst) return;
|
||||
const labels = { tool: 'Werkzeug', layer: 'Layer', library: 'Bibliothek', ki: 'KI Copilot' };
|
||||
if (titleEl) titleEl.textContent = labels[panel] || 'Panel';
|
||||
dst.innerHTML = '';
|
||||
const clone = src.cloneNode(true);
|
||||
clone.classList.add('active');
|
||||
clone.style.cssText = 'display: block; width: 100%;';
|
||||
dst.appendChild(clone);
|
||||
// Re-wire KI chips and library categories inside drawer
|
||||
dst.querySelectorAll('.ki-chip').forEach(chip => {
|
||||
chip.addEventListener('click', () => {
|
||||
const input = dst.querySelector('.ki-input');
|
||||
if (input) { input.value = chip.textContent.trim(); input.focus(); }
|
||||
});
|
||||
});
|
||||
dst.querySelectorAll('.lib-cat').forEach(cat => {
|
||||
cat.addEventListener('click', () => {
|
||||
dst.querySelectorAll('.lib-cat').forEach(c => c.classList.remove('active'));
|
||||
cat.classList.add('active');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===================== RIBBON TAB SWITCHING =====================
|
||||
document.querySelectorAll('.ribbon-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.ribbon-tab').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); });
|
||||
tab.classList.add('active');
|
||||
tab.setAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== TOOL SELECTION (desktop) =====================
|
||||
document.querySelectorAll('.leftbar .tool-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.leftbar .tool-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== LEFTBAR TOGGLE (desktop) =====================
|
||||
const leftToggle = document.getElementById('leftbar-toggle');
|
||||
if (leftToggle) {
|
||||
leftToggle.addEventListener('click', () => {
|
||||
const main = document.querySelector('.main');
|
||||
if (main) main.classList.toggle('left-collapsed');
|
||||
});
|
||||
}
|
||||
|
||||
// ===================== CANVAS VIEW TABS =====================
|
||||
document.querySelectorAll('.canvas-view-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.canvas-view-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== STATUS BAR TOGGLES =====================
|
||||
document.querySelectorAll('.status-item').forEach(item => {
|
||||
item.addEventListener('click', () => item.classList.toggle('active'));
|
||||
});
|
||||
|
||||
// ===================== CANVAS TOOLBAR (grid, ortho, snap) =====================
|
||||
['btn-grid', 'btn-ortho', 'btn-snap'].forEach(id => {
|
||||
const b = document.getElementById(id);
|
||||
if (b) {
|
||||
b.addEventListener('click', () => {
|
||||
b.classList.toggle('active');
|
||||
const pressed = b.classList.contains('active');
|
||||
b.setAttribute('aria-pressed', pressed ? 'true' : 'false');
|
||||
// Also toggle matching status bar item
|
||||
const map = { 'btn-grid': 'GRID', 'btn-ortho': 'ORTHO', 'btn-snap': 'SNAP' };
|
||||
const label = map[id];
|
||||
if (label) {
|
||||
document.querySelectorAll('.status-item').forEach(s => {
|
||||
if (s.textContent.trim().startsWith(label)) s.classList.toggle('active', pressed);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ===================== ZOOM =====================
|
||||
let zoom = 100;
|
||||
const zoomDisplay = document.getElementById('zoom-display');
|
||||
document.getElementById('btn-zoom-in')?.addEventListener('click', () => {
|
||||
zoom = Math.min(400, zoom + 25);
|
||||
if (zoomDisplay) zoomDisplay.textContent = zoom + '%';
|
||||
});
|
||||
document.getElementById('btn-zoom-out')?.addEventListener('click', () => {
|
||||
zoom = Math.max(10, zoom - 25);
|
||||
if (zoomDisplay) zoomDisplay.textContent = zoom + '%';
|
||||
});
|
||||
document.getElementById('btn-zoom-fit')?.addEventListener('click', () => {
|
||||
zoom = 100;
|
||||
if (zoomDisplay) zoomDisplay.textContent = zoom + '%';
|
||||
});
|
||||
|
||||
// ===================== LIBRARY CATEGORIES (desktop) =====================
|
||||
document.querySelectorAll('.rightbar .lib-cat').forEach(cat => {
|
||||
cat.addEventListener('click', () => {
|
||||
document.querySelectorAll('.rightbar .lib-cat').forEach(c => c.classList.remove('active'));
|
||||
cat.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== KI CHIPS (desktop) =====================
|
||||
document.querySelectorAll('.rightbar .ki-chip').forEach(chip => {
|
||||
chip.addEventListener('click', () => {
|
||||
const input = document.querySelector('.rightbar .ki-input');
|
||||
if (input) { input.value = chip.textContent.trim(); input.focus(); }
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== KEYBOARD SHORTCUTS =====================
|
||||
const toolMap = { v: 'select', l: 'line', c: 'circle', m: 'move', p: 'pan', z: 'zoom-win', e: 'erase' };
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.matches('input, textarea, select')) return;
|
||||
if (e.key === 'Escape') return; // handled by drawer
|
||||
const k = e.key.toLowerCase();
|
||||
if (toolMap[k]) {
|
||||
const btn = document.querySelector(`.tool-btn[data-tool="${toolMap[k]}"]`);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// ===================== TREE COMPONENT TOGGLE =====================
|
||||
(function initTrees() {
|
||||
// Delegate click on [data-tree-toggle] rows (folder rows)
|
||||
document.addEventListener('click', (e) => {
|
||||
const toggleBtn = e.target.closest('.tree-toggle');
|
||||
if (toggleBtn) {
|
||||
const row = toggleBtn.closest('[data-tree-toggle]');
|
||||
if (!row) return;
|
||||
e.stopPropagation();
|
||||
const node = row.parentElement;
|
||||
toggleTreeNode(node);
|
||||
return;
|
||||
}
|
||||
// Click anywhere on folder row also toggles (except the count which is passive)
|
||||
const folderRow = e.target.closest('[data-tree-toggle]');
|
||||
if (folderRow && !e.target.closest('.tree-count')) {
|
||||
const node = folderRow.parentElement;
|
||||
toggleTreeNode(node);
|
||||
}
|
||||
});
|
||||
// Leaf selection (single-select within siblings)
|
||||
document.addEventListener('click', (e) => {
|
||||
const leaf = e.target.closest('[data-tree-select]');
|
||||
if (!leaf) return;
|
||||
e.stopPropagation();
|
||||
// Remove .active from siblings within same parent tree
|
||||
const tree = leaf.closest('.tree');
|
||||
if (tree) {
|
||||
tree.querySelectorAll('[data-tree-select].active').forEach(el => el.classList.remove('active'));
|
||||
}
|
||||
leaf.classList.add('active');
|
||||
});
|
||||
|
||||
function toggleTreeNode(node) {
|
||||
if (!node || !node.classList.contains('tree-node')) return;
|
||||
const expanded = node.getAttribute('aria-expanded') === 'true';
|
||||
node.setAttribute('aria-expanded', expanded ? 'false' : 'true');
|
||||
const children = node.querySelector(':scope > .tree-children');
|
||||
if (children) {
|
||||
if (expanded) { children.setAttribute('hidden', ''); }
|
||||
else { children.removeAttribute('hidden'); }
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-and-drop for library leafs (visual only - mockup)
|
||||
document.addEventListener('dragstart', (e) => {
|
||||
const draggable = e.target.closest('.lib-draggable');
|
||||
if (!draggable) return;
|
||||
draggable.classList.add('dragging');
|
||||
const name = draggable.querySelector('.tree-name')?.textContent || 'Block';
|
||||
e.dataTransfer.setData('text/plain', name);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
});
|
||||
document.addEventListener('dragend', (e) => {
|
||||
const draggable = e.target.closest('.lib-draggable');
|
||||
if (draggable) draggable.classList.remove('dragging');
|
||||
});
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
/* web-cad v6 – Design Tokens, Typography, Base (with Drawer tokens) */
|
||||
:root {
|
||||
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace;
|
||||
|
||||
--fs-xs: 11px;
|
||||
--fs-sm: 12px;
|
||||
--fs-md: 13px;
|
||||
--fs-lg: 14px;
|
||||
--fs-xl: 16px;
|
||||
--fs-2xl: 20px;
|
||||
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-hover: #1d4ed8;
|
||||
--color-primary-light: #dbeafe;
|
||||
--color-primary-50: #eff6ff;
|
||||
|
||||
--color-bg: #f8fafc;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #f1f5f9;
|
||||
--color-surface-3: #e2e8f0;
|
||||
--color-border: #e2e8f0;
|
||||
--color-border-strong: #cbd5e1;
|
||||
|
||||
--color-text: #0f172a;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-faint: #94a3b8;
|
||||
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-ki: #8b5cf6;
|
||||
--color-ki-light: #ede9fe;
|
||||
|
||||
--color-canvas-bg: #0f172a;
|
||||
--color-canvas-bg-2: #1e293b;
|
||||
--color-grid-minor: rgba(255,255,255,0.06);
|
||||
--color-grid-major: rgba(255,255,255,0.12);
|
||||
--color-grid-axis: rgba(96,165,250,0.28);
|
||||
--color-online: #10b981;
|
||||
|
||||
--radius-xs: 3px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 6px;
|
||||
--spacing-md: 10px;
|
||||
--spacing-lg: 14px;
|
||||
--spacing-xl: 20px;
|
||||
|
||||
/* Layout */
|
||||
--topbar-h: 38px;
|
||||
--ribbon-h: 72px;
|
||||
--leftbar-w: 200px;
|
||||
--rightbar-w: 300px;
|
||||
--cmdline-h: 56px;
|
||||
--status-h: 26px;
|
||||
--canvas-toolbar-h: 38px;
|
||||
|
||||
/* Mobile-specific */
|
||||
--mobile-right-tab-w: 56px;
|
||||
--mobile-topbar-h: 44px;
|
||||
--mobile-ribbon-h: 48px;
|
||||
--drawer-w: min(320px, 88vw);
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(15,23,42,0.04);
|
||||
--shadow-md: 0 2px 6px -1px rgba(15,23,42,0.08), 0 1px 3px -1px rgba(15,23,42,0.06);
|
||||
--shadow-lg: 0 10px 20px -4px rgba(15,23,42,0.10), 0 4px 8px -2px rgba(15,23,42,0.06);
|
||||
--shadow-drawer: -8px 0 24px -4px rgba(15,23,42,0.18);
|
||||
|
||||
--t-fast: 120ms cubic-bezier(.2,.0,.2,1);
|
||||
--t-base: 200ms cubic-bezier(.2,.0,.2,1);
|
||||
--t-drawer: 280ms cubic-bezier(.32, .72, 0, 1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #0b0e14;
|
||||
--color-surface: #11151c;
|
||||
--color-surface-2: #161b24;
|
||||
--color-surface-3: #1d2330;
|
||||
--color-border: #232a36;
|
||||
--color-border-strong: #2f3849;
|
||||
--color-text: #e2e8f0;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-text-faint: #64748b;
|
||||
--color-primary-light: #1e3a8a;
|
||||
--color-primary-50: #172554;
|
||||
--color-ki-light: #2e1065;
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.3);
|
||||
--shadow-md: 0 2px 6px -1px rgba(0,0,0,0.4);
|
||||
--shadow-lg: 0 10px 20px -4px rgba(0,0,0,0.5);
|
||||
--shadow-drawer: -8px 0 24px -4px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
*::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-md);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
/* iOS safe areas */
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
body {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
a { color: var(--color-primary); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-strong);
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--color-bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--color-text-faint); }
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 8px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
z-index: 1000;
|
||||
font-weight: 500;
|
||||
}
|
||||
.skip-link:focus { top: 8px; }
|
||||
|
||||
/* Scrim (drawer backdrop) */
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
z-index: 90;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity var(--t-drawer), visibility 0s linear var(--t-drawer);
|
||||
}
|
||||
.scrim.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity var(--t-drawer), visibility 0s linear 0s;
|
||||
}
|
||||
+1133
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "web-cad-neu",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Web-basiertes 2D-CAD für Event-Bestuhlungspläne - Clean Rewrite",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev:frontend": "cd frontend && npm run dev",
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
"dev": "concurrently \"npm:dev:frontend\" \"npm:dev:backend\"",
|
||||
"build:frontend": "cd frontend && npm run build",
|
||||
"build:backend": "cd backend && npm run build",
|
||||
"build": "npm run build:frontend && npm run build:backend",
|
||||
"test": "cd frontend && npm test && cd ../backend && npm test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
# Phase 2 — React UI Components Task Spec
|
||||
|
||||
## Context
|
||||
- Project: web-cad-neu (clean rewrite of web-cad)
|
||||
- Frontend: /a0/usr/workdir/web-cad-neu/frontend/
|
||||
- Stack: React 18 + TypeScript + Vite, plain CSS (no Tailwind/styled-components)
|
||||
- tsconfig: jsx=react-jsx, baseUrl=./src, paths @/* -> .
|
||||
|
||||
## Reference Files (READ THESE FIRST)
|
||||
- Mockup HTML: /a0/usr/workdir/web-cad-neu/mockup/editor.html (1133 lines)
|
||||
- Mockup theme CSS: /a0/usr/workdir/web-cad-neu/mockup/assets/styles.css (195 lines)
|
||||
- Mockup layout CSS: /a0/usr/workdir/web-cad-neu/mockup/assets/layout.css (1298 lines)
|
||||
- Types: /a0/usr/workdir/web-cad-neu/frontend/src/types/cad.types.ts
|
||||
- Phase 1 engines: /a0/usr/workdir/web-cad-neu/frontend/src/canvas/*.ts, /a0/usr/workdir/web-cad-neu/frontend/src/interaction/index.ts
|
||||
|
||||
## Output Directory
|
||||
/a0/usr/workdir/web-cad-neu/frontend/src/
|
||||
|
||||
## Files to Create (15 components + 1 CSS)
|
||||
|
||||
### 1. styles.css
|
||||
- Copy /a0/usr/workdir/web-cad-neu/mockup/assets/styles.css + layout.css content into ONE file at frontend/src/styles.css
|
||||
- Keep all CSS class names verbatim from mockup
|
||||
- Keep all CSS variables from styles.css
|
||||
- Add mobile breakpoint rules from layout.css (<=768px)
|
||||
|
||||
### 2. App.tsx
|
||||
- Root component: imports styles.css
|
||||
- Layout: <div class="app"> > Topbar + RibbonBar + <main class="main"> > LeftSidebar + CanvasArea + RightSidebar + </main> + CommandLine + StatusBar + MobileDrawers
|
||||
- State: activeTool, activeRibbonTab, activeRightPanel, theme, snapEnabled, orthoEnabled, gridEnabled, selectedElements, layers, cursorPos, commandHistory
|
||||
- Pass props to children
|
||||
|
||||
### 3. components/Topbar.tsx
|
||||
- class="topbar" with topbar-left (hamburger, logo, app-name, project-name, saved-badge) and topbar-right (undo, redo, divider, versions, share, divider, theme-toggle, help, divider, lang-select, notifications, avatar)
|
||||
- Copy SVG icons EXACTLY from mockup lines 21-68
|
||||
- Props: projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme
|
||||
|
||||
### 4. components/RibbonBar.tsx
|
||||
- class="ribbon" with ribbon-tabs (6 tabs: Start/Einfügen/Format/Ansicht/Extras/KI) + ribbon-content
|
||||
- Start tab groups: Datei (Neu/Öffnen/Speichern), Bearbeiten (Undo/Redo/Kopieren/Einfügen), Ansicht (Zoom Fit/100%/Grid/Layer), Extras (Messen/Suchen/KI/Plugins)
|
||||
- Copy SVGs from mockup lines 72-191
|
||||
- Props: activeTab, onTabChange, onAction
|
||||
|
||||
### 5. components/LeftSidebar.tsx
|
||||
- class="leftbar" with header (title + toggle), 4 tool-sections, footer (3 buttons)
|
||||
- Sections: Auswählen/Anzeigen (Auswahl/Pan/Zoom/Messen), Zeichnen (Linie/Polylinie/Rechteck/Kreis/Bogen/Text/Bemaßung/Schraffur), Bearbeiten (Move/Copy/Rotate/Scale/Mirror/Trim/Offset/Löschen), Bestuhlung (Reihe/Block/Tisch/Bühne)
|
||||
- 20 tool buttons with data-tool, title, SVG icon, label, kbd shortcut
|
||||
- Copy SVGs from mockup lines 198-360
|
||||
- Props: activeTool, onToolChange
|
||||
- Footer: Plugin/Settings/Help buttons
|
||||
|
||||
### 6. components/CanvasArea.tsx
|
||||
- class="canvas-area" with canvas-coords (X/Y display), canvas-view-tabs (2D/ISO/Front/Top), <canvas> element (NOT SVG)
|
||||
- useEffect: init RenderEngine + ZoomPanController + InteractionEngine on canvas ref
|
||||
- Floating toolbar: zoom in/out/fit, grid toggle, ortho toggle, snap toggle, prev view
|
||||
- Props: cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled, onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit
|
||||
|
||||
### 7. components/RightSidebar.tsx
|
||||
- class="rightbar" with rightbar-tabs (Werkzeug/Layer/Bibliothek/KI) + rightbar-content
|
||||
- Tab switching shows/hides panels
|
||||
- KI tab has badge (count)
|
||||
- Copy SVGs from mockup lines 669-687
|
||||
- Props: activePanel, onPanelChange, selectedElement, layers, blocks
|
||||
|
||||
### 8. components/PropertiesPanel.tsx
|
||||
- rightbar-panel data-panel-content="tool"
|
||||
- Sections: selection info, Geometrie (X/Y/Breite/Tiefe/Drehung inputs), Darstellung (Farbe color picker+hex, Linientyp select, Stärke select, Layer select), Block (name+description inputs)
|
||||
- Copy structure from mockup lines 692-776
|
||||
- Props: selectedElement, layers, onUpdateProperty
|
||||
|
||||
### 9. components/LayerPanel.tsx
|
||||
- rightbar-panel data-panel-content="layer"
|
||||
- Uses TreeView component for layer hierarchy
|
||||
- Header with options button + add-layer button
|
||||
- Copy structure from mockup lines 779-866
|
||||
- Props: layers, onSelectLayer, onAddLayer, onToggleLayer
|
||||
|
||||
### 10. components/BlockLibrary.tsx
|
||||
- rightbar-panel data-panel-content="library"
|
||||
- lib-search (input with search icon), lib-categories (Alle/Bestuhlung/Tische/Bühne/Deko chips), tree-library with draggable items
|
||||
- Copy structure from mockup lines 897-976
|
||||
- Props: blocks, category, onCategoryChange, onSearch, onDragBlock
|
||||
|
||||
### 11. components/KICopilot.tsx
|
||||
- rightbar-panel data-panel-content="ki"
|
||||
- ki-header (avatar, title, status), ki-suggestions (chips), ki-chat (messages), ki-input-wrap (input+voice+send)
|
||||
- Copy structure from mockup lines 978-1026
|
||||
- Props: messages, suggestions, onSend, onSuggestionClick
|
||||
|
||||
### 12. components/CommandLine.tsx
|
||||
- class="cmdline" with cmdline-history (entries with prefix · or ›) + cmdline-input-wrap (prompt › + input)
|
||||
- Copy structure from mockup lines 1076-1089
|
||||
- Props: history, onCommand
|
||||
|
||||
### 13. components/StatusBar.tsx
|
||||
- class="statusbar" with status-items: SNAP/ORTHO/POLAR/GRID toggles, X/Y coords, active layer, active tool, online status
|
||||
- Copy structure from mockup lines 1092-1127
|
||||
- Props: snapEnabled, orthoEnabled, polarEnabled, gridEnabled, cursorX, cursorY, activeLayer, activeTool, onlineCount, onToggleSnap, onToggleOrtho, onTogglePolar, onToggleGrid
|
||||
|
||||
### 14. components/TreeView.tsx
|
||||
- Reusable tree component for LayerPanel and BlockLibrary
|
||||
- Props: nodes (TreeNode[]), onSelect, onToggle, draggable, renderIcon
|
||||
- TreeNode type: { id, name, count?, icon?, children?, expanded?, active?, draggable? }
|
||||
- Renders tree-node > tree-row (tree-row-folder or tree-row-leaf) > tree-children recursively
|
||||
|
||||
### 15. components/MobileDrawers.tsx
|
||||
- Scrim + drawer-left + drawer-right
|
||||
- drawer-left: header (title + close) + body (filled with LeftSidebar content)
|
||||
- drawer-right: header (title + close) + drawer-tabs (Wz/Layer/Lib/KI) + body
|
||||
- Copy structure from mockup lines 1032-1073
|
||||
- Props: leftOpen, rightOpen, activeRightTab, onCloseLeft, onCloseRight, onRightTabChange
|
||||
|
||||
## Rules
|
||||
- TypeScript functional components with React.FC or function syntax
|
||||
- NO inline styles except where mockup uses them (style="...")
|
||||
- NO styled-components, NO Tailwind
|
||||
- Import styles.css once in App.tsx
|
||||
- Copy SVG icons EXACTLY from mockup (viewBox, paths, attributes)
|
||||
- Use mockup CSS class names verbatim
|
||||
- All text in German (matching mockup)
|
||||
- CanvasArea uses <canvas> not <svg> — wire to Phase 1 engines
|
||||
- Mobile breakpoint <=768px: hamburger shows, sidebars hide, drawers slide in
|
||||
- npx tsc --noEmit must pass with zero errors
|
||||
- Each component in its own file under components/
|
||||
@@ -0,0 +1,153 @@
|
||||
# Phase 3: Drawing Tools — React Wiring & State Management
|
||||
|
||||
## Goal
|
||||
Wire Phase 1 canvas/interaction engines to React state so drawing tools actually create, render, and manage elements. Implement undo/redo, command-line shortcuts, and drawing flow improvements.
|
||||
|
||||
## Requirements Covered
|
||||
- F-CAD-01: Zeichnen von Grundelementen (Linie, Kreis, Bogen, Rechteck, Polygon, Polylinie)
|
||||
- F-CAD-03: Bemaßung
|
||||
- F-CAD-05: Command Line (L, C, PL, R, A, T, DIM shortcuts)
|
||||
- F-CAD-09: Undo/Redo mit History
|
||||
- F-CAD-11: Text
|
||||
|
||||
## Current State
|
||||
- `src/interaction/index.ts` (591 lines): InteractionEngine with drawing logic for all types, callbacks (onElementCreated, onElementsDeleted, onToolStateChanged, onCursorMoved, onCommandTrigger)
|
||||
- `src/canvas/RenderEngine.ts` (588 lines): Renders all element types
|
||||
- `src/components/CanvasArea.tsx` (103 lines): Creates engine instances in useEffect but does NOT wire callbacks to React state
|
||||
- `src/App.tsx` (223 lines): Has mock state but no element management, no engine ref, no undo/redo
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Refactor CanvasArea to expose engine instances
|
||||
File: `src/components/CanvasArea.tsx`
|
||||
|
||||
Current: engines created in useEffect, no external access.
|
||||
|
||||
Required:
|
||||
- Use `useRef` to hold engine instances (InteractionEngine, RenderEngine, ZoomPanController, etc.)
|
||||
- Accept new props: `onElementCreated`, `onElementsDeleted`, `onCursorMoved`, `onToolStateChanged`, `onToolChange` (tool from App → engine), `elements` (element array → engine.setElements), `snapEnabled`, `orthoEnabled`, `gridEnabled` (from App → engine state)
|
||||
- In useEffect: create engines, wire all callbacks to props, call `interaction.setCallbacks({...})`
|
||||
- Add a separate useEffect for `elements` changes → `interaction.setElements(elements)` + `spatialIndex.bulkInsert(elements)` + `renderEngine.render()`
|
||||
- Add a separate useEffect for `snapEnabled/orthoEnabled/gridEnabled` → update engine state + renderEngine options
|
||||
- Add a separate useEffect for `activeTool` → `interaction.setTool(tool)`
|
||||
- Expose zoomPan via ref callback prop `onZoomPanReady` so App can call zoomFit
|
||||
- Add `onZoomIn`, `onZoomOut`, `onZoomFit` to actually call zoomPan methods (not just log)
|
||||
- Add double-click handler: on dblclick, if drawing polyline/polygon → call confirmDraw via interaction
|
||||
|
||||
New CanvasAreaProps (add to `src/types/ui.types.ts`):
|
||||
```typescript
|
||||
export interface CanvasAreaProps {
|
||||
cursorPos: CursorPos;
|
||||
viewMode: ViewMode;
|
||||
onViewChange: (mode: ViewMode) => void;
|
||||
gridEnabled: boolean;
|
||||
orthoEnabled: boolean;
|
||||
snapEnabled: boolean;
|
||||
activeTool: string;
|
||||
elements: CADElement[];
|
||||
onElementCreated: (el: CADElement) => void;
|
||||
onElementsDeleted: (ids: string[]) => void;
|
||||
onCursorMoved: (x: number, y: number) => void;
|
||||
onToolStateChanged: (state: ToolState) => void;
|
||||
onToggleGrid: () => void;
|
||||
onToggleOrtho: () => void;
|
||||
onToggleSnap: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onZoomFit: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Task 2: Implement undo/redo stack in App.tsx
|
||||
File: `src/App.tsx`
|
||||
|
||||
Required:
|
||||
- Add `elements` state: `useState<CADElement[]>([])`
|
||||
- Add `undoStack` and `redoStack` as `useState<CADElement[][]>`
|
||||
- `handleElementCreated`: push current elements to undoStack, add new element, clear redoStack
|
||||
- `handleElementsDeleted`: push current elements to undoStack, remove deleted, clear redoStack
|
||||
- `handleUndo`: if undoStack not empty, push current to redoStack, pop undoStack → set elements
|
||||
- `handleRedo`: if redoStack not empty, push current to undoStack, pop redoStack → set elements
|
||||
- Pass `elements` to CanvasArea
|
||||
- Pass `onElementCreated`, `onElementsDeleted` to CanvasArea
|
||||
- Wire `onCursorMoved` to update `cursorPos` state
|
||||
- Wire `onToolStateChanged` to update any relevant UI state
|
||||
|
||||
### Task 3: Wire tool selection from LeftSidebar to CanvasArea
|
||||
File: `src/App.tsx`
|
||||
|
||||
Required:
|
||||
- `activeTool` state already exists in App.tsx
|
||||
- Pass `activeTool` to CanvasArea as prop
|
||||
- CanvasArea useEffect watches `activeTool` → calls `interaction.setTool(tool as ToolType)`
|
||||
- LeftSidebar `onToolChange` already updates `activeTool` in App
|
||||
|
||||
### Task 4: Command line shortcuts (F-CAD-05)
|
||||
File: `src/App.tsx`
|
||||
|
||||
Required:
|
||||
- In `handleCommand(cmd)`, parse command and map to tool:
|
||||
- `L` or `LINE` → setTool('line')
|
||||
- `C` or `CIRCLE` → setTool('circle')
|
||||
- `A` or `ARC` → setTool('arc')
|
||||
- `R` or `RECT` or `RECTANGLE` → setTool('rect')
|
||||
- `PL` or `POLYLINE` → setTool('polyline')
|
||||
- `POL` or `POLYGON` → setTool('polygon')
|
||||
- `T` or `TEXT` → setTool('text')
|
||||
- `DIM` or `DIMENSION` → setTool('dimension')
|
||||
- `V` or `SELECT` → setTool('select')
|
||||
- `P` or `PAN` → setTool('pan')
|
||||
- `M` or `MOVE` → setTool('move')
|
||||
- `CO` or `COPY` → setTool('copy')
|
||||
- `RO` or `ROTATE` → setTool('rotate')
|
||||
- `SC` or `SCALE` → setTool('scale')
|
||||
- `MI` or `MIRROR` → setTool('mirror')
|
||||
- `DEL` or `ERASE` → setTool('delete')
|
||||
- `UNDO` or `U` → handleUndo()
|
||||
- `REDO` → handleRedo()
|
||||
- Add command to history with prefix '›'
|
||||
- Add response to history with prefix '·' (e.g. 'Linie-Werkzeug aktiv · Klicken zum Starten')
|
||||
|
||||
### Task 5: Drawing flow improvements
|
||||
File: `src/interaction/index.ts`
|
||||
|
||||
Required:
|
||||
- **Polyline/Polygon**: Add `handleDoubleClick` method — if activeTool is polyline/polygon and phase is drawing, call confirmDraw()
|
||||
- Wire dblclick event in `attach()`: `this.addListener('dblclick', this.onDoubleClick.bind(this))`
|
||||
- **Arc**: Improve 3-point arc — first click = center, second click = start point (sets radius + startAngle), third click = end point (sets endAngle). Update handleDrawDown to handle multi-point arc.
|
||||
- **Text**: After placing text element, trigger an inline edit prompt. For now, use a simple `prompt()` dialog or add `onTextEdit` callback.
|
||||
|
||||
### Task 6: Grid/Snap/Ortho toggle wiring
|
||||
File: `src/components/CanvasArea.tsx`
|
||||
|
||||
Required:
|
||||
- When `gridEnabled` prop changes: `renderEngine.setOptions({ showGrid: gridEnabled })` + re-render
|
||||
- When `snapEnabled` prop changes: `interaction.state.snapEnabled = snapEnabled` + `renderEngine.setOptions({ showSnapPoints: snapEnabled })` + re-render
|
||||
- When `orthoEnabled` prop changes: `interaction.state.ortho = orthoEnabled` + `renderEngine.setOptions({ showOrtho: orthoEnabled })` + re-render
|
||||
|
||||
## Constraints
|
||||
- TypeScript functional components, no inline styles (except where mockup uses style)
|
||||
- No styled-components, no Tailwind
|
||||
- `npx tsc --noEmit` must pass with 0 errors
|
||||
- `npx vite build` must succeed
|
||||
- Minimal changes — extend existing files, don't rewrite
|
||||
- Keep all existing CSS class names
|
||||
- Import ToolState type from interaction/index.ts where needed
|
||||
|
||||
## File Summary
|
||||
1. `src/types/ui.types.ts` — update CanvasAreaProps
|
||||
2. `src/components/CanvasArea.tsx` — expose engines, wire callbacks, add useEffects
|
||||
3. `src/App.tsx` — element state, undo/redo, command shortcuts, wire all callbacks
|
||||
4. `src/interaction/index.ts` — double-click handler, arc 3-point, text edit callback
|
||||
|
||||
## Test Criteria
|
||||
1. `npx tsc --noEmit` — 0 errors
|
||||
2. `npx vite build` — success
|
||||
3. Drawing a line: select Line tool → click start → move mouse → Enter → element appears
|
||||
4. Drawing a rect: select Rect tool → click corner → move mouse → Enter → element appears
|
||||
5. Drawing a circle: select Circle tool → click center → move mouse → Enter → element appears
|
||||
6. Polyline: select Polyline → click multiple points → double-click → element appears
|
||||
7. Command line: type 'L' → line tool activates
|
||||
8. Undo/Redo: draw element → Ctrl+Z → element disappears → Ctrl+Y → element reappears
|
||||
9. Grid toggle: click grid button → grid appears/disappears
|
||||
10. Snap toggle: click snap button → snap points appear/disappear on hover
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user