Replace frontend with real working CAD editor source from /data/web-cad-neu (TS, React 18, full ribbon bar, KI, layers, tools)

This commit is contained in:
Agent Zero
2026-06-28 10:00:32 +02:00
parent b56c65815a
commit 2109f0544b
117 changed files with 22344 additions and 5121 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
*.md
*.log
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3 -13
View File
@@ -1,20 +1,10 @@
FROM node:22-alpine AS build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
RUN apk add --no-cache curl
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-16
View File
@@ -1,16 +0,0 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
-21
View File
@@ -1,21 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
+11 -12
View File
@@ -1,13 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
<!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>
+19 -20
View File
@@ -1,25 +1,24 @@
server {
listen 80;
server_name localhost;
listen 80;
root /usr/share/nginx/html;
index index.html;
root /usr/share/nginx/html;
index index.html;
resolver 127.0.0.11 valid=30s;
# Serve static frontend
location / {
try_files $uri /index.html;
}
location / { try_files $uri $uri/ /index.html; }
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
set $backend http://backend:3001;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /ws {
set $backend http://backend:3001;
proxy_pass $backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
+2251 -2652
View File
File diff suppressed because it is too large Load Diff
+24 -20
View File
@@ -1,30 +1,34 @@
{
"name": "frontend",
"name": "web-cad-frontend",
"version": "0.1.0",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"axios": "^1.16.1",
"fabric": "^5.5.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.1"
"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": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"vite": "^8.0.12"
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/rbush": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^29.1.1",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vitest": "^2.0.0"
}
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
-35
View File
@@ -1,35 +0,0 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import Editor from './pages/Editor';
const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();
if (loading) return <div>Loading...</div>;
if (!user) return <Navigate to="/login" />;
return children;
};
const AppContent = () => (
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/editor/:id" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
<Route path="/editor/new" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/dashboard" />} />
</Routes>
</Router>
);
const App = () => (
<AuthProvider>
<AppContent />
</AuthProvider>
);
export default App;
+1089
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

+195
View File
@@ -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;
}
}
+985
View File
@@ -0,0 +1,985 @@
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 previewElement: CADElement | 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;
}
setPreviewElement(el: CADElement | null): void {
this.previewElement = el;
}
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 && e.properties?.visible !== false);
for (const el of els) {
this.drawElement(el, layer);
}
}
// Draw preview element with dashed style
if (this.previewElement) {
this.ctx.save();
this.ctx.setLineDash([6, 4]);
const previewEl: CADElement = { ...this.previewElement, properties: { ...this.previewElement.properties, stroke: '#00aaff', strokeWidth: 2 } };
const previewLayer: CADLayer = { id: '__preview__', name: 'Preview', visible: true, locked: false, color: '#00aaff', lineType: 'solid', transparency: 0, sortOrder: 999, parentId: null };
this.drawElement(previewEl, previewLayer);
this.ctx.restore();
}
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;
});
}
}
+297
View File
@@ -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;
}
}
+402
View File
@@ -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' };
}
}
+48
View File
@@ -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,
};
}
}
+99
View File
@@ -0,0 +1,99 @@
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;
const minX = -this.offsetX / this.scale || 0;
const minY = -this.offsetY / this.scale || 0;
return {
minX,
minY,
maxX: minX + w,
maxY: minY + 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;
-104
View File
@@ -1,104 +0,0 @@
import React, { useState, useEffect } from 'react';
import api from '../services/api';
const BlockLibrary = ({ canvasRef }) => {
const [blocks, setBlocks] = useState([]);
const [categories, setCategories] = useState([]);
const [activeCategory, setActiveCategory] = useState('Alle');
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/blocks')
.then(res => {
const data = res.data || [];
setBlocks(data);
const cats = ['Alle', ...new Set(data.map(b => b.category || 'Allgemein'))];
setCategories(cats);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const filteredBlocks = activeCategory === 'Alle'
? blocks
: blocks.filter(b => (b.category || 'Allgemein') === activeCategory);
const handleDragStart = (e, block) => {
e.dataTransfer.setData('text/plain', block.svg_data);
};
const handleDrop = (e) => {
e.preventDefault();
const svgData = e.dataTransfer.getData('text/plain');
if (svgData && canvasRef.current) {
canvasRef.current.addSvgObject(svgData);
}
};
const handleDragOver = (e) => {
e.preventDefault();
};
if (loading) return <div style={{ padding: 10 }}>Lade...</div>;
return (
<div
style={{ padding: '0 10px 10px' }}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* Category filter */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
style={{
padding: '2px 8px',
fontSize: 11,
background: activeCategory === cat ? '#1890ff' : '#f0f0f0',
color: activeCategory === cat ? 'white' : '#333',
border: 'none',
borderRadius: 3,
cursor: 'pointer',
}}
>
{cat}
</button>
))}
</div>
{/* Block grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
{filteredBlocks.map(block => (
<div
key={block.id}
draggable
onDragStart={(e) => handleDragStart(e, block)}
style={{
border: '1px solid #ddd',
borderRadius: 4,
padding: 4,
background: 'white',
cursor: 'grab',
textAlign: 'center',
}}
title={block.name}
>
<div
dangerouslySetInnerHTML={{ __html: block.svg_data }}
style={{ width: '100%', aspectRatio: '1', marginBottom: 4 }}
/>
<div style={{ fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{block.name}
</div>
</div>
))}
</div>
{filteredBlocks.length === 0 && (
<p style={{ fontSize: 12, color: '#999' }}>Keine Blöcke in dieser Kategorie.</p>
)}
</div>
);
};
export default BlockLibrary;
+141
View File
@@ -0,0 +1,141 @@
import React, { useState, useRef } from 'react';
import type { BlockLibraryProps } from '../types/ui.types';
import type { BlockDefinition } from '../types/cad.types';
const categories = ['Alle', 'Bestuhlung', 'Tische', 'Bühne', 'Architektur', 'Custom'];
const BlockLibrary: React.FC<BlockLibraryProps> = ({ blocks, category, onCategoryChange, onSearch, onDragBlock, onRenameBlock, onDuplicateBlock, onDeleteBlock, onSvgImport, onSaveGroupAsBlock }) => {
const [searchQuery, setSearchQuery] = useState('');
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set(['Bestuhlung']));
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const q = e.target.value;
setSearchQuery(q);
onSearch(q);
};
const filteredBlocks = blocks.filter(b => {
const catMatch = category === 'Alle' || b.category === category;
const q = searchQuery.toLowerCase().trim();
const searchMatch = !q || b.name.toLowerCase().includes(q) || b.description.toLowerCase().includes(q);
return catMatch && searchMatch;
});
// Group by category
const grouped: Record<string, BlockDefinition[]> = {};
for (const b of filteredBlocks) {
if (!grouped[b.category]) grouped[b.category] = [];
grouped[b.category].push(b);
}
const toggleCat = (cat: string) => {
setExpandedCats((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
};
const handleDragStart = (e: React.DragEvent, blockId: string) => {
e.dataTransfer.setData('text/block-id', blockId);
e.dataTransfer.effectAllowed = 'copy';
onDragBlock(blockId);
};
const handleSvgImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const svgContent = ev.target?.result as string;
if (onSvgImport) {
onSvgImport(svgContent, file.name.replace(/\.svg$/i, ''), 'Custom');
} else {
window.dispatchEvent(new CustomEvent('block-svg-import', { detail: { svg: svgContent, name: file.name.replace(/\.svg$/i, ''), category: 'Custom' } }));
}
};
reader.readAsText(file);
e.target.value = '';
};
return (
<>
<div className="lib-search">
<span className="lib-search-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</span>
<input type="text" placeholder="Block suchen..." aria-label="Bibliothek durchsuchen" value={searchQuery} onChange={handleSearch} />
</div>
<div className="lib-categories">
{categories.map((cat) => (
<button key={cat} className={`lib-cat${category === cat ? ' active' : ''}`} onClick={() => onCategoryChange(cat)}>{cat}</button>
))}
</div>
<div className="lib-import">
<button className="lib-import-btn" title="SVG importieren" onClick={() => fileInputRef.current?.click()}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span>SVG importieren</span>
</button>
{onSaveGroupAsBlock && (
<button className="lib-import-btn" title="Auswahl als Block speichern" onClick={() => { const name = window.prompt('Block-Name:', ''); if (name) onSaveGroupAsBlock(name); }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
<span>Als Block speichern</span>
</button>
)}
<input ref={fileInputRef} type="file" accept=".svg,image/svg+xml" style={{ display: 'none' }} onChange={handleSvgImport} />
</div>
<div className="tree tree-library" role="tree" aria-label="Bibliothek-Struktur">
{Object.entries(grouped).map(([cat, catBlocks]) => (
<div key={cat} className="lib-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;
-253
View File
@@ -1,253 +0,0 @@
import React, { useEffect, useRef, forwardRef, useImperativeHandle, useState } from 'react';
import { fabric } from 'fabric';
const CADCanvas = forwardRef(({ activeTool, setActiveTool, onCanvasReady }, ref) => {
const canvasEl = useRef(null);
const fabricRef = useRef(null);
const [zoom, setZoom] = useState(1);
const isDrawing = useRef(false);
const startPoint = useRef(null);
const tempShape = useRef(null);
useImperativeHandle(ref, () => ({
getCanvas: () => fabricRef.current,
toJSON: () => fabricRef.current?.toJSON(),
loadFromJSON: (json) => {
if (fabricRef.current) {
fabricRef.current.loadFromJSON(json, fabricRef.current.renderAll.bind(fabricRef.current));
}
},
addSvgObject: (svgStr) => {
if (!fabricRef.current) return;
fabric.loadSVGFromString(svgStr, (objects, options) => {
const group = fabric.util.groupSVGElements(objects, options);
group.set({ left: 100, top: 100, scaleX: 0.5, scaleY: 0.5 });
fabricRef.current.add(group);
fabricRef.current.setActiveObject(group);
fabricRef.current.renderAll();
});
},
addObjects: (objects) => {
if (!fabricRef.current) return;
objects.forEach(obj => {
let shape;
if (obj.type === 'line') {
shape = new fabric.Line([obj.x1, obj.y1, obj.x2, obj.y2], { stroke: obj.stroke || '#000', strokeWidth: obj.strokeWidth || 1 });
} else if (obj.type === 'circle') {
shape = new fabric.Circle({ left: obj.left, top: obj.top, radius: obj.radius, fill: obj.fill || 'transparent', stroke: obj.stroke || '#000', strokeWidth: obj.strokeWidth || 1 });
} else if (obj.type === 'polygon') {
shape = new fabric.Polygon(obj.points.map(p => ({ x: p.x, y: p.y })), { fill: obj.fill || 'transparent', stroke: obj.stroke || '#000', strokeWidth: obj.strokeWidth || 1 });
} else if (obj.type === 'rect') {
shape = new fabric.Rect({ left: obj.left, top: obj.top, width: obj.width, height: obj.height, fill: obj.fill || 'transparent', stroke: obj.stroke || '#000', strokeWidth: obj.strokeWidth || 1 });
}
if (shape) fabricRef.current.add(shape);
});
fabricRef.current.renderAll();
}
}));
// Initialize fabric canvas
useEffect(() => {
if (canvasEl.current && !fabricRef.current) {
fabricRef.current = new fabric.Canvas(canvasEl.current, {
width: canvasEl.current.parentElement.clientWidth,
height: canvasEl.current.parentElement.clientHeight,
backgroundColor: '#fff',
selection: true,
});
if (onCanvasReady) onCanvasReady(fabricRef.current);
const handleResize = () => {
if (canvasEl.current && fabricRef.current) {
fabricRef.current.setWidth(canvasEl.current.parentElement.clientWidth);
fabricRef.current.setHeight(canvasEl.current.parentElement.clientHeight);
fabricRef.current.renderAll();
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (fabricRef.current) {
fabricRef.current.dispose();
fabricRef.current = null;
}
};
}
}, []);
// Zoom with mouse wheel
useEffect(() => {
if (!fabricRef.current) return;
const canvas = fabricRef.current;
const handleWheel = (opt) => {
const delta = opt.e.deltaY;
let zoom = canvas.getZoom();
zoom *= 0.999 ** delta;
if (zoom > 20) zoom = 20;
if (zoom < 0.01) zoom = 0.01;
canvas.zoomToPoint({ x: opt.e.offsetX, y: opt.e.offsetY }, zoom);
opt.e.preventDefault();
opt.e.stopPropagation();
setZoom(Math.round(zoom * 100) / 100);
};
canvas.on('mouse:wheel', handleWheel);
return () => canvas.off('mouse:wheel', handleWheel);
}, []);
// Drawing tool behavior
useEffect(() => {
if (!fabricRef.current) return;
const canvas = fabricRef.current;
canvas.off('mouse:down');
canvas.off('mouse:move');
canvas.off('mouse:up');
if (activeTool === 'select') {
canvas.isDrawingMode = false;
canvas.selection = true;
canvas.forEachObject(obj => obj.selectable = true);
return;
}
canvas.selection = false;
canvas.forEachObject(obj => obj.selectable = false);
if (activeTool === 'freehand') {
canvas.isDrawingMode = true;
canvas.freeDrawingBrush.width = 2;
canvas.freeDrawingBrush.color = '#000';
return;
}
canvas.isDrawingMode = false;
canvas.on('mouse:down', (opt) => {
isDrawing.current = true;
const pointer = canvas.getPointer(opt.e);
startPoint.current = { x: pointer.x, y: pointer.y };
if (activeTool === 'line') {
tempShape.current = new fabric.Line(
[pointer.x, pointer.y, pointer.x, pointer.y],
{ stroke: '#000', strokeWidth: 2 }
);
} else if (activeTool === 'rect') {
tempShape.current = new fabric.Rect({
left: pointer.x,
top: pointer.y,
width: 0,
height: 0,
fill: 'transparent',
stroke: '#000',
strokeWidth: 2,
});
} else if (activeTool === 'circle') {
tempShape.current = new fabric.Circle({
left: pointer.x,
top: pointer.y,
radius: 0,
fill: 'transparent',
stroke: '#000',
strokeWidth: 2,
});
} else if (activeTool === 'text') {
const text = new fabric.IText('Text', {
left: pointer.x,
top: pointer.y,
fontSize: 20,
fill: '#000',
});
canvas.add(text);
canvas.setActiveObject(text);
isDrawing.current = false;
return;
} else if (activeTool === 'dimension') {
const line = new fabric.Line([pointer.x, pointer.y, pointer.x, pointer.y], {
stroke: '#e74c3c', strokeWidth: 1, strokeDashArray: [5, 5]
});
const tick1 = new fabric.Line([pointer.x, pointer.y, pointer.x+10, pointer.y], { stroke: '#e74c3c', strokeWidth: 1 });
const tick2 = new fabric.Line([pointer.x+10, pointer.y, pointer.x+10, pointer.y+10], { stroke: '#e74c3c', strokeWidth: 1 });
const text = new fabric.Text('0', {
left: pointer.x, top: pointer.y - 20, fontSize: 12, fill: '#e74c3c',
});
const group = new fabric.Group([line, tick1, tick2, text], {
selectable: false, hasControls: false, hasBorders: false,
});
tempShape.current = { group, line, text, tick1, tick2, start: {x: pointer.x, y: pointer.y} };
canvas.add(group);
}
if (tempShape.current && !(activeTool === 'dimension')) {
canvas.add(tempShape.current);
}
});
canvas.on('mouse:move', (opt) => {
if (!isDrawing.current || !tempShape.current) return;
const pointer = canvas.getPointer(opt.e);
if (activeTool === 'line') {
tempShape.current.set({ x2: pointer.x, y2: pointer.y });
} else if (activeTool === 'rect') {
const start = startPoint.current;
let left = Math.min(start.x, pointer.x);
let top = Math.min(start.y, pointer.y);
let width = Math.abs(start.x - pointer.x);
let height = Math.abs(start.y - pointer.y);
tempShape.current.set({ left, top, width, height });
} else if (activeTool === 'circle') {
const start = startPoint.current;
const radius = Math.sqrt(
(pointer.x - start.x) ** 2 + (pointer.y - start.y) ** 2
) / 2;
tempShape.current.set({
left: start.x - radius,
top: start.y - radius,
radius: radius,
});
} else if (activeTool === 'dimension') {
const { group, line, text, tick1, tick2, start } = tempShape.current;
// Update line
line.set({ x2: pointer.x, y2: pointer.y });
// Compute distance
const dx = pointer.x - start.x;
const dy = pointer.y - start.y;
const dist = Math.sqrt(dx*dx + dy*dy).toFixed(1);
text.set({ text: dist });
// Update text position to middle of line
const midX = (start.x + pointer.x) / 2;
const midY = (start.y + pointer.y) / 2;
text.set({ left: midX - 15, top: midY - 20 });
// Update tick positions (simplified: put at start and end)
tick1.set({ x1: start.x, y1: start.y, x2: start.x+10, y2: start.y });
tick2.set({ x1: pointer.x, y1: pointer.y, x2: pointer.x-10, y2: pointer.y });
// Add corner tick lines (just simple markers)
// Refresh group
group.addWithUpdate();
}
canvas.renderAll();
});
canvas.on('mouse:up', () => {
if (isDrawing.current && activeTool === 'dimension' && tempShape.current) {
// Make dimension group selectable after placement
tempShape.current.group.set({ selectable: true, hasControls: false, hasBorders: true });
}
isDrawing.current = false;
tempShape.current = null;
});
}, [activeTool]);
return (
<div style={{ width: '100%', height: '100%' }}>
<canvas ref={canvasEl} />
<div style={{ position: 'absolute', bottom: 10, right: 10, background: 'rgba(255,255,255,0.8)', padding: '4px 8px', borderRadius: 4, fontSize: 12 }}>
Zoom: {Math.round(zoom * 100)}%
</div>
</div>
);
});
export default CADCanvas;
-26
View File
@@ -1,26 +0,0 @@
.canvas-area {
flex: 1;
background-color: var(--background);
overflow: hidden;
position: relative;
}
.canvas-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--secondary);
font-family: var(--font-family);
}
.canvas-placeholder p {
margin: 0;
padding: 0;
}
.placeholder-subtext {
font-size: var(--font-size-sm);
margin-top: var(--spacing-sm);
}
+357
View File
@@ -0,0 +1,357 @@
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 CanvasArea: React.FC<CanvasAreaProps> = ({
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
polarEnabled,
activeTool, elements, layers, activeLayerId, 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);
const layerManagerRef = useRef<LayerManager | 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;
layerManagerRef.current = layerManager;
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;
layerManagerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update callbacks when they change (avoids stale closures)
useEffect(() => {
const interaction = interactionRef.current;
if (!interaction) return;
interaction.setCallbacks({
onElementCreated,
onElementsDeleted,
onElementsModified,
onCursorMoved,
onToolStateChanged,
onTextEdit,
onCommandTrigger,
onSelectionChange,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onTextEdit, onCommandTrigger, onSelectionChange]);
// 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;
const layerManager = layerManagerRef.current;
if (!renderEngine) return;
if (layerManager) {
layerManager.clear();
layers.forEach(l => layerManager.addLayer(l));
}
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 active layer to LayerManager
useEffect(() => {
const layerManager = layerManagerRef.current;
if (!layerManager || !activeLayerId) return;
layerManager.setActiveLayer(activeLayerId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLayerId, layers]);
// 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>
<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;
-111
View File
@@ -1,111 +0,0 @@
.command-line {
background-color: var(--surface);
border-top: var(--border-width) solid var(--border);
padding: var(--spacing-sm);
min-height: 60px;
}
.command-form {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.command-label {
font-family: var(--font-family);
font-size: var(--font-size-sm);
color: var(--text);
font-weight: var(--font-weight-medium);
}
.command-input-container {
display: flex;
gap: var(--spacing-sm);
}
.command-input {
flex: 1;
padding: var(--spacing-sm);
border: var(--border-width) solid var(--border);
border-radius: var(--border-radius);
background-color: var(--background);
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
}
.command-input:focus {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
.command-submit {
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--primary);
color: var(--background);
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-family: var(--font-family);
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
transition: background-color var(--transition-normal);
}
.command-submit:hover {
background-color: var(--primary-dark);
}
.suggestions-list {
position: absolute;
background-color: var(--surface);
border: var(--border-width) solid var(--border);
border-radius: var(--border-radius);
max-height: 200px;
overflow-y: auto;
z-index: 100;
list-style: none;
padding: 0;
margin: var(--spacing-xs) 0 0 0;
width: 300px;
}
.suggestion-item {
padding: var(--spacing-sm);
cursor: pointer;
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
}
.suggestion-item:hover {
background-color: var(--hover);
}
/* Screen reader only */
.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;
}
/* Responsive design */
@media (max-width: 768px) {
.command-input-container {
flex-direction: column;
}
.command-submit {
width: 100%;
}
.suggestions-list {
width: calc(100% - 2 * var(--spacing-sm));
}
}
+253
View File
@@ -0,0 +1,253 @@
import React, { useState, useRef, useMemo, useEffect } from 'react';
import type { CommandLineProps } from '../types/ui.types';
import { getCommandRegistry, type CommandDefinition } from '../services/commandRegistry';
const defaultHistory = [
{ prefix: '·' as const, text: 'Bereit · Werkzeug: Auswahl · 110 Objekte · 5 Ebenen', type: 'info' as const },
{ prefix: '·' as const, text: 'Auto-Save aktiv · letzte Speicherung vor 3 Sekunden', type: 'info' as const },
{ prefix: '' as const, text: 'hallo', type: 'command' as const },
{ prefix: '·' as const, text: 'Hallo! Ich bin der KI Copilot. Tippe KI oder drücke Strg+K für Hilfe.', type: 'info' as const },
{ prefix: '' as const, text: 'BESTUHLUNG 5,22', type: 'command' as const },
{ prefix: '·' as const, text: '110 Stühle angelegt auf Ebene "Bestuhlung" ✓', type: 'info' as const },
];
const categoryColors: Record<string, string> = {
draw: '#22c55e',
modify: '#f97316',
view: '#06b6d4',
meta: '#a855f7',
special: '#ec4899',
};
const CommandLine: React.FC<CommandLineProps> = ({ history, onCommand }) => {
const [input, setInput] = useState('');
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const historyRef = useRef<HTMLDivElement>(null);
const entries = history.length > 0 ? history : defaultHistory;
const suggestions = useMemo(() => {
if (!input.trim()) return [];
const registry = getCommandRegistry();
return registry.autocomplete(input.trim());
}, [input]);
useEffect(() => {
setSelectedSuggestion(0);
}, [suggestions]);
useEffect(() => {
if (historyRef.current) {
historyRef.current.scrollTop = historyRef.current.scrollHeight;
}
}, [entries]);
const executeCommand = (cmd: string) => {
const trimmed = cmd.trim();
if (!trimmed) return;
onCommand(trimmed);
setCommandHistory((prev) => [...prev, trimmed]);
setInput('');
setShowSuggestions(false);
setHistoryIndex(-1);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
const navKeys = ['ArrowUp', 'ArrowDown', 'Tab', 'Enter', 'Escape'];
if (showSuggestions && suggestions.length > 0 && navKeys.includes(e.key)) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedSuggestion((prev) => Math.min(prev + 1, suggestions.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedSuggestion((prev) => Math.max(prev - 1, 0));
return;
}
if (e.key === 'Tab') {
e.preventDefault();
const suggestion = suggestions[selectedSuggestion] || suggestions[0];
if (suggestion) {
setInput(suggestion.name);
setShowSuggestions(false);
}
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const suggestion = suggestions[selectedSuggestion];
if (suggestion) {
executeCommand(suggestion.name);
} else {
executeCommand(input);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setShowSuggestions(false);
return;
}
}
if (!showSuggestions || suggestions.length === 0) {
if (e.key === 'Enter' && input.trim()) {
executeCommand(input);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (commandHistory.length === 0) return;
const newIdx = historyIndex === -1 ? commandHistory.length - 1 : Math.max(historyIndex - 1, 0);
setHistoryIndex(newIdx);
setInput(commandHistory[newIdx]);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex === -1) return;
const newIdx = historyIndex + 1;
if (newIdx >= commandHistory.length) {
setHistoryIndex(-1);
setInput('');
} else {
setHistoryIndex(newIdx);
setInput(commandHistory[newIdx]);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setInput('');
setShowSuggestions(false);
setHistoryIndex(-1);
return;
}
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
setShowSuggestions(true);
setHistoryIndex(-1);
};
const handleBlur = () => {
setTimeout(() => setShowSuggestions(false), 150);
};
const handleFocus = () => {
if (input.trim()) setShowSuggestions(true);
};
const handleSuggestionClick = (cmd: CommandDefinition) => {
executeCommand(cmd.name);
};
return (
<section className="cmdline" aria-label="Befehlszeile">
<div className="cmdline-history" id="cmdline-history" aria-live="polite" ref={historyRef}>
{entries.map((entry, i) => (
<div key={i} className={`cmdline-history-entry${entry.type === 'info' ? ' info' : ''}`}>
<span className="prefix">{entry.prefix}</span>
<span className="text">{entry.text}</span>
</div>
))}
</div>
<div className="cmdline-input-wrap" style={{ position: 'relative' }}>
{showSuggestions && suggestions.length > 0 && (
<div
className="cmdline-suggestions"
style={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
background: '#1e293b',
border: '1px solid #334155',
borderRadius: '6px 6px 0 0',
maxHeight: '240px',
overflowY: 'auto',
zIndex: 1000,
boxShadow: '0 -4px 12px rgba(0,0,0,0.3)',
}}
>
{suggestions.map((cmd, i) => (
<div
key={cmd.name}
className={`cmdline-suggestion${i === selectedSuggestion ? ' selected' : ''}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 12px',
cursor: 'pointer',
background: i === selectedSuggestion ? '#334155' : 'transparent',
color: '#e2e8f0',
fontSize: '13px',
borderBottom: i < suggestions.length - 1 ? '1px solid #334155' : 'none',
}}
onMouseDown={(e) => {
e.preventDefault();
handleSuggestionClick(cmd);
}}
onMouseEnter={() => setSelectedSuggestion(i)}
>
<span
className="cmdline-suggestion-badge"
style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: '4px',
fontSize: '10px',
fontWeight: 600,
textTransform: 'uppercase',
background: categoryColors[cmd.category] || '#64748b',
color: '#fff',
minWidth: '44px',
textAlign: 'center',
}}
>
{cmd.category}
</span>
<span className="cmdline-suggestion-name" style={{ fontWeight: 600 }}>
{cmd.name}
</span>
{cmd.aliases.length > 0 && (
<span className="cmdline-suggestion-aliases" style={{ color: '#64748b', fontSize: '11px' }}>
({cmd.aliases.join(', ')})
</span>
)}
<span className="cmdline-suggestion-desc" style={{ color: '#94a3b8', fontSize: '12px', marginLeft: 'auto' }}>
{cmd.description}
</span>
</div>
))}
</div>
)}
<span className="cmdline-prompt"></span>
<input
className="cmdline-input"
type="text"
id="cmdline-input"
placeholder='Befehl eingeben (z. B. LINIE, KREIS, BESTUHLUNG)...'
aria-label="CAD Befehlseingabe"
autoComplete="off"
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={handleFocus}
ref={inputRef}
/>
</div>
</section>
);
};
export default CommandLine;
+77
View File
@@ -0,0 +1,77 @@
/**
* HistoryPanel Zeigt die Undo/Redo-Historie an.
* F-CAD-09: Historie einsehbar
*/
import React from 'react';
import type { HistoryEntry } from '../history';
interface HistoryPanelProps {
entries: HistoryEntry[];
onJumpTo: (entryId: string) => void;
onClose: () => void;
}
const formatTime = (ts: number): string => {
const d = new Date(ts);
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
};
const HistoryPanel: React.FC<HistoryPanelProps> = ({ entries, onJumpTo, onClose }) => {
if (entries.length === 0) {
return (
<div style={{
position: 'fixed', top: '50%', right: '320px', transform: 'translateY(-50%)',
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
padding: '16px', width: '280px', maxHeight: '60vh', overflow: 'auto',
border: '1px solid var(--color-border, #333)', zIndex: 900,
color: 'var(--color-text, #e0e0e0)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '15px' }}>Historie</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: '18px' }}>×</button>
</div>
<p style={{ fontSize: '13px', color: '#888' }}>Keine Historie vorhanden.</p>
</div>
);
}
return (
<div style={{
position: 'fixed', top: '50%', right: '320px', transform: 'translateY(-50%)',
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
padding: '16px', width: '280px', maxHeight: '60vh', overflow: 'auto',
border: '1px solid var(--color-border, #333)', zIndex: 900,
color: 'var(--color-text, #e0e0e0)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '15px' }}>Historie ({entries.length})</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: '18px' }}>×</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{entries.map((entry, idx) => (
<button
key={entry.id}
onClick={() => onJumpTo(entry.id)}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '6px 8px', borderRadius: '4px', cursor: 'pointer',
border: 'none', textAlign: 'left', width: '100%',
background: entry.isCurrent ? 'rgba(80, 250, 123, 0.15)' : 'transparent',
color: entry.isCurrent ? '#50fa7b' : 'var(--color-text, #e0e0e0)',
fontWeight: entry.isCurrent ? 600 : 400,
fontSize: '13px',
opacity: entry.id.startsWith('redo-') ? 0.5 : 1,
}}
>
<span style={{ fontSize: '11px', color: '#666', minWidth: '28px' }}>{formatTime(entry.timestamp)}</span>
<span style={{ flex: 1 }}>{entry.label}</span>
{entry.isCurrent && <span style={{ fontSize: '10px' }}></span>}
</button>
))}
</div>
</div>
);
};
export default HistoryPanel;
+88
View File
@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import type { KICopilotProps } from '../types/ui.types';
const defaultSuggestions = [
{ id: 's1', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>, label: '5 Reihen à 22 Stühle anlegen' },
{ id: 's2', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>, label: 'Optimale Bestuhlung vorschlagen' },
{ id: 's3', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>, label: 'Überlappende Stühle finden' },
];
const defaultMessages = [
{ id: 'm1', role: 'user' as const, content: 'Lege 5 Reihen mit je 22 Stühlen parallel zur Bühne an, Abstand 1m.' },
{ id: 'm2', role: 'assistant' as const, content: <span>Ich erstelle <strong>110 Stühle</strong> in 5 Reihen (Y=2,5m/3,5m/4,5m/5,5m/6,5m; X-Start=2,5m; 22 Stühle × 0,5m + 0,1m Abstand). Los geht's! <em style={{ color: 'var(--color-ki)' }}>● function_call: placeSeating(rows=5, cols=22, gap=1.0)</em></span> },
];
const KICopilot: React.FC<KICopilotProps> = ({ messages, suggestions, onSend, onSuggestionClick, loading }) => {
const [input, setInput] = useState('');
const allMessages = messages.length > 0 ? messages : defaultMessages;
const allSuggestions = suggestions.length > 0 ? suggestions : defaultSuggestions;
const handleSend = () => {
if (input.trim()) {
onSend(input.trim());
setInput('');
}
};
return (
<>
<div className="ki-header">
<div className="ki-avatar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
</div>
<div>
<div className="ki-title">KI Copilot</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>Powered by Claude</div>
</div>
<span className="ki-status">Online</span>
</div>
<div className="ki-suggestions">
<div className="ki-suggestion-title">Schnell-Aktionen</div>
{allSuggestions.map((s) => (
<button key={s.id} className="ki-chip" onClick={() => onSuggestionClick(s)}>
{s.icon}
{s.label}
</button>
))}
</div>
<div className="ki-chat">
{allMessages.map((msg) => (
<div key={msg.id} className={`ki-msg ${msg.role}`}>
<div className="ki-msg-bubble">{msg.content}</div>
</div>
))}
{loading && (
<div className="ki-msg assistant">
<div className="ki-msg-bubble" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className="ki-typing-dot" />
<span className="ki-typing-dot" />
<span className="ki-typing-dot" />
</div>
</div>
)}
</div>
<div className="ki-input-wrap">
<input
className="ki-input"
type="text"
placeholder="Frage den KI Copilot..."
aria-label="KI Eingabe"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSend(); }}
/>
<button className="ki-voice-btn" title="Spracheingabe" aria-label="Spracheingabe starten">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</button>
<button className="ki-send-btn" title="Senden" aria-label="Senden" onClick={handleSend} disabled={loading}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</>
);
};
export default KICopilot;
-97
View File
@@ -1,97 +0,0 @@
import React, { useState } from 'react';
const LayerPanel = ({ layers, setLayers, activeLayer, setActiveLayer }) => {
const [newLayerName, setNewLayerName] = useState('');
const addLayer = () => {
if (!newLayerName.trim()) return;
setLayers([...layers, { name: newLayerName, visible: true, locked: false }]);
setNewLayerName('');
};
const deleteLayer = (name) => {
if (layers.length <= 1) return;
setLayers(layers.filter(l => l.name !== name));
if (activeLayer === name) setActiveLayer(layers[0].name);
};
const toggleVisible = (name) => {
setLayers(layers.map(l => l.name === name ? { ...l, visible: !l.visible } : l));
};
const toggleLocked = (name) => {
setLayers(layers.map(l => l.name === name ? { ...l, locked: !l.locked } : l));
};
return (
<div style={{ padding: '0 10px 10px' }}>
<div style={{ display: 'flex', gap: 5, marginBottom: 10 }}>
<input
type="text"
placeholder="Neuer Layer..."
value={newLayerName}
onChange={(e) => setNewLayerName(e.target.value)}
style={{ flex: 1, padding: 4, borderRadius: 3, border: '1px solid #ccc' }}
/>
<button onClick={addLayer} style={{ padding: '4px 8px', background: '#1890ff', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer' }}>+</button>
</div>
{layers.map((layer, idx) => (
<div
key={idx}
onClick={() => setActiveLayer(layer.name)}
style={{
padding: '6px 8px',
marginBottom: 4,
background: activeLayer === layer.name ? '#e6f7ff' : '#fff',
border: activeLayer === layer.name ? '1px solid #1890ff' : '1px solid #ddd',
borderRadius: 4,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: 13,
}}
>
<span style={{ flex: 1 }}>{layer.name}</span>
<span style={{ display: 'flex', gap: 4 }}>
<button
onClick={(e) => { e.stopPropagation(); toggleVisible(layer.name); }}
style={{
width: 24, height: 24, fontSize: 12,
background: layer.visible ? '#52c41a' : '#d9d9d9',
color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer',
}}
title={layer.visible ? 'Sichtbar' : 'Ausgeblendet'}
>
{layer.visible ? '👁' : '—'}
</button>
<button
onClick={(e) => { e.stopPropagation(); toggleLocked(layer.name); }}
style={{
width: 24, height: 24, fontSize: 12,
background: layer.locked ? '#faad14' : '#d9d9d9',
color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer',
}}
title={layer.locked ? 'Gesperrt' : 'Entsperrt'}
>
{layer.locked ? '🔒' : '🔓'}
</button>
<button
onClick={(e) => { e.stopPropagation(); deleteLayer(layer.name); }}
style={{
width: 24, height: 24, fontSize: 12,
background: '#ff4d4f', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer',
visibility: layers.length > 1 ? 'visible' : 'hidden',
}}
title="Löschen"
>
</button>
</span>
</div>
))}
</div>
);
};
export default LayerPanel;
+209
View File
@@ -0,0 +1,209 @@
import React from 'react';
import type { LayerPanelProps } from '../types/ui.types';
import type { CADLayer, CADElement } from '../types/cad.types';
import type { TreeNode } from '../types/ui.types';
import TreeView from './TreeView';
const elementTypeLabels: Record<string, string> = {
line: 'Linie',
rect: 'Rechteck',
circle: 'Kreis',
arc: 'Bogen',
polyline: 'Polylinie',
polygon: 'Polygon',
text: 'Text',
dimension: 'Bemassung',
leader: 'Hinweislinie',
revcloud: 'Revisionswolke',
hatch: 'Schraffur',
chair: 'Stuhl',
'seating-row': 'Reihe',
'seating-block': 'Block',
table: 'Tisch',
stage: 'Buhne',
'seating-template': 'Vorlage',
};
const layerIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="12,2 22,8 12,14 2,8" />
<polyline points="2,16 12,22 22,16" />
<polyline points="2,12 12,18 22,12" />
</svg>
);
const elementIcons: Record<string, React.ReactNode> = {
line: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="20" x2="20" y2="4"/></svg>,
rect: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="4" width="16" height="16"/></svg>,
circle: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="8"/></svg>,
arc: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 20 A16 16 0 0 1 20 4"/></svg>,
polyline: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="4,18 8,8 14,14 20,6"/></svg>,
polygon: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="12,4 20,10 16,20 8,20 4,10"/></svg>,
text: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 7V4h16v3M9 20h6M12 4v16"/></svg>,
dimension: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 16 L20 16 M4 16 L4 12 M20 16 L20 12 M8 16 L8 14 M16 16 L16 14"/></svg>,
leader: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 4 L12 12 L12 20"/><circle cx="4" cy="4" r="2"/></svg>,
revcloud: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 16 Q6 12 10 14 Q12 10 16 14 Q20 12 20 16 Q20 20 16 20 L8 20 Q4 20 4 16Z"/></svg>,
hatch: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="4" width="16" height="16"/><line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="16" x2="20" y2="16"/></svg>,
chair: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 4v8h12V4 M6 12v8 M18 12v8 M4 12h16"/></svg>,
'seating-row': <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="8" width="4" height="8"/><rect x="8" y="8" width="4" height="8"/><rect x="14" y="8" width="4" height="8"/><rect x="20" y="8" width="2" height="8"/></svg>,
'seating-block': <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="4" width="6" height="6"/><rect x="10" y="4" width="6" height="6"/><rect x="2" y="12" width="6" height="6"/><rect x="10" y="12" width="6" height="6"/></svg>,
table: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="6" width="16" height="12"/><line x1="4" y1="10" x2="20" y2="10"/><line x1="4" y1="14" x2="20" y2="14"/></svg>,
stage: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 18 L12 6 L20 18Z"/></svg>,
'seating-template': <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="4" width="16" height="16"/><circle cx="8" cy="8" r="1"/><circle cx="12" cy="8" r="1"/><circle cx="16" cy="8" r="1"/><circle cx="8" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="16" cy="12" r="1"/></svg>,
};
const defaultIcon = <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="8"/></svg>;
const LayerPanel: React.FC<LayerPanelProps> = ({
layers,
elements = [],
activeLayerId,
onSelectLayer,
onAddLayer,
onToggleLayer,
onDeleteLayer,
onRenameLayer,
onDuplicateLayer,
onToggleLock,
onReorder,
onAddSubLayer,
onElementsDeleted,
onToggleElementVisible,
}) => {
const buildTree = (parentId: string | null): TreeNode[] => {
return layers
.filter((l) => l.parentId === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((l) => {
const layerElements = elements.filter((e) => e.layerId === l.id);
const elementNodes: TreeNode[] = layerElements.map((e) => ({
id: e.id,
name: elementTypeLabels[e.type] || e.type,
icon: elementIcons[e.type] || defaultIcon,
expanded: false,
active: false,
children: [],
}));
const subLayerNodes = buildTree(l.id);
return {
id: l.id,
name: l.name,
icon: layerIcon,
expanded: false,
active: l.id === activeLayerId,
children: [...subLayerNodes, ...elementNodes],
count: layerElements.length > 0 ? layerElements.length : undefined,
} as TreeNode;
});
};
const tree = buildTree(null);
const handleAddSubLayer = (parentId: string) => {
if (onAddSubLayer) {
onAddSubLayer(parentId);
}
};
return (
<div className="layer-panel" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid var(--border-color, #333)' }}>
<span style={{ fontWeight: 600, fontSize: '13px' }}>Ebenen</span>
<button
onClick={onAddLayer}
title="Neue Ebene"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-color, #ccc)', padding: '4px' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '4px 0' }}>
{tree.length === 0 ? (
<div style={{ padding: '12px', textAlign: 'center', color: 'var(--text-muted, #888)', fontSize: '12px' }}>
Keine Ebenen vorhanden
</div>
) : (
<TreeView
nodes={tree}
selectedId={activeLayerId}
onSelect={onSelectLayer}
onReorder={onReorder}
renderIcon={(node) => node.icon}
renderActions={(node) => {
const layer = layers.find((l) => l.id === node.id);
if (layer) {
return (
<div style={{ display: 'flex', gap: '2px', opacity: 0.6 }}>
<button
onClick={(e) => { e.stopPropagation(); onToggleLayer(layer.id); }}
title={layer.visible ? 'Sichtbar' : 'Versteckt'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: layer.visible ? 'var(--text-color, #ccc)' : 'var(--text-muted, #666)' }}
>
{layer.visible ? '👁' : '🚫'}
</button>
<button
onClick={(e) => { e.stopPropagation(); onToggleLock?.(layer.id); }}
title={layer.locked ? 'Gesperrt' : 'Entsperrt'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: layer.locked ? 'var(--accent, #f59e0b)' : 'var(--text-muted, #666)' }}
>
{layer.locked ? '🔒' : '🔓'}
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAddSubLayer(layer.id); }}
title="Teilebene hinzufugen"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--text-color, #ccc)' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onDuplicateLayer?.(layer.id); }}
title="Duplizieren"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--text-color, #ccc)' }}
>
</button>
<button
onClick={(e) => { e.stopPropagation(); onDeleteLayer?.(layer.id); }}
title="Loschen"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: '#f44336' }}
>
</button>
</div>
);
}
const element = elements.find((el) => el.id === node.id);
if (element) {
const isVisible = element.properties?.visible !== false;
return (
<div style={{ display: 'flex', gap: '2px', opacity: 0.6 }}>
<button
onClick={(e) => { e.stopPropagation(); onToggleElementVisible?.(element.id); }}
title={isVisible ? 'Sichtbar' : 'Versteckt'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: isVisible ? 'var(--text-color, #ccc)' : 'var(--text-muted, #666)' }}
>
{isVisible ? '👁' : '🚫'}
</button>
<button
onClick={(e) => { e.stopPropagation(); onElementsDeleted?.([element.id]); }}
title="Loschen"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: '#f44336' }}
>
</button>
</div>
);
}
return null;
}}
/>
)}
</div>
</div>
);
};
export default LayerPanel;
+138
View File
@@ -0,0 +1,138 @@
import React from 'react';
import type { LeftSidebarProps } from '../types/ui.types';
import { SEATING_TEMPLATES } from '../services/seatingService';
interface ToolDef {
tool: string;
title: string;
label: string;
kbd: string;
svg: React.ReactNode;
}
const sectionAuswahlen: ToolDef[] = [
{ tool: 'select', title: 'Auswählen (V)', label: 'Auswahl', kbd: 'V', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="m13 13 6 6"/></svg> },
{ tool: 'pan', title: 'Pan (P / Leertaste)', label: 'Pan', kbd: 'P', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2 2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg> },
{ tool: 'zoom-win', title: 'Zoom-Fenster (Z)', label: 'Zoom', kbd: 'Z', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/></svg> },
{ tool: 'measure', title: 'Messen', label: 'Messen', kbd: 'M', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0L2.7 16.7a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4z"/><path d="m7.5 10.5 2 2"/><path d="m10.5 7.5 2 2"/></svg> },
];
const sectionZeichnen: ToolDef[] = [
{ tool: 'line', title: 'Linie (L)', label: 'Linie', kbd: 'L', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="19" x2="19" y2="5"/></svg> },
{ tool: 'polyline', title: 'Polylinie (PL)', label: 'Polylinie', kbd: 'PL', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 14 15 20 7"/></svg> },
{ tool: 'rect', title: 'Rechteck (REC)', label: 'Rechteck', kbd: 'REC', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="1"/></svg> },
{ tool: 'circle', title: 'Kreis (C)', label: 'Kreis', kbd: 'C', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg> },
{ tool: 'arc', title: 'Bogen (A)', label: 'Bogen', kbd: 'A', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/><path d="M3 12a9 9 0 0 1 9-9"/></svg> },
{ tool: 'text', title: 'Text (T)', label: 'Text', kbd: 'T', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg> },
{ tool: 'dimension', title: 'Bemaßung (DIM)', label: 'Bemaßung', kbd: 'DIM', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 3H3l3 18 3-9 6 9 3-9 3 9 3-18z"/><line x1="3" y1="3" x2="21" y2="21"/></svg> },
{ tool: 'hatch', title: 'Schraffur (H)', label: 'Schraffur', kbd: 'H', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="0"/><line x1="3" y1="9" x2="9" y2="3"/><line x1="9" y1="21" x2="21" y2="9"/><line x1="15" y1="21" x2="21" y2="15"/></svg> },
{ tool: 'leader', title: 'Hinweislinie (LD)', label: 'Hinweis', kbd: 'LD', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21 12 12"/><path d="M12 12 18 6"/><polyline points="18 3 18 6 21 6"/><line x1="18" y1="6" x2="21" y2="3"/></svg> },
{ tool: 'revcloud', title: 'Revisionswolke (REV)', label: 'RevCloud', kbd: 'REV', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 18c-1-3 1-5 3-4 0-3 3-4 5-2 1-3 5-3 6 0 2-1 5 1 4 4 1 2-2 4-4 3-1 2-4 2-5 0-2 1-5-1-4-3-2-1-3-4-1-5z"/></svg> },
];
const sectionBearbeiten: ToolDef[] = [
{ tool: 'move', title: 'Verschieben (M)', label: 'Move', kbd: 'M', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg> },
{ tool: 'copy', title: 'Kopieren (CO)', label: 'Copy', kbd: 'CO', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> },
{ tool: 'rotate', title: 'Rotieren (RO)', label: 'Rotate', kbd: 'RO', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> },
{ tool: 'scale', title: 'Skalieren (SC)', label: 'Scale', kbd: 'SC', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg> },
{ tool: 'mirror', title: 'Spiegeln (MI)', label: 'Mirror', kbd: 'MI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 8l5-5 5 5"/><path d="M5 16l5 5 5-5"/></svg> },
{ tool: 'trim', title: 'Trimmen (TR)', label: 'Trim', kbd: 'TR', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> },
{ tool: 'offset', title: 'Versatz (O)', label: 'Offset', kbd: 'O', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h12"/><path d="M3 12h18"/><path d="M3 18h12"/><path d="M21 6v12"/></svg> },
{ tool: 'delete', title: 'Löschen (E)', label: 'Löschen', kbd: 'E', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> },
];
const sectionBestuhlung: ToolDef[] = [
{ tool: 'seating-row', title: 'Reihen-Bestuhlung', label: 'Reihe', kbd: 'R', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="4" height="3" rx="0.5"/><rect x="10" y="5" width="4" height="3" rx="0.5"/><rect x="17" y="5" width="4" height="3" rx="0.5"/><rect x="3" y="11" width="4" height="3" rx="0.5"/><rect x="10" y="11" width="4" height="3" rx="0.5"/><rect x="17" y="11" width="4" height="3" rx="0.5"/><rect x="3" y="17" width="4" height="3" rx="0.5"/><rect x="10" y="17" width="4" height="3" rx="0.5"/><rect x="17" y="17" width="4" height="3" rx="0.5"/></svg> },
{ tool: 'seating-block', title: 'Block-Bestuhlung', label: 'Block', kbd: 'B', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><rect x="6" y="6" width="3" height="3"/><rect x="10.5" y="6" width="3" height="3"/><rect x="15" y="6" width="3" height="3"/><rect x="6" y="10.5" width="3" height="3"/><rect x="10.5" y="10.5" width="3" height="3"/><rect x="15" y="10.5" width="3" height="3"/><rect x="6" y="15" width="3" height="3"/><rect x="10.5" y="15" width="3" height="3"/><rect x="15" y="15" width="3" height="3"/></svg> },
{ tool: 'table', title: 'Tisch (TAB)', label: 'Tisch', kbd: 'TAB', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="9" width="16" height="6" rx="1"/><line x1="6" y1="15" x2="6" y2="19"/><line x1="18" y1="15" x2="18" y2="19"/></svg> },
{ tool: 'stage', title: 'Bühne', label: 'Bühne', kbd: 'S', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 17h20v3H2z"/><path d="M5 14V8h14v6"/><path d="M9 14v-3"/><path d="M15 14v-3"/></svg> },
{ tool: 'seating-template', title: 'Vorlagen', label: 'Vorlagen', kbd: 'TPL', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg> },
];
const sections: Array<{ label: string; tools: ToolDef[] }> = [
{ label: 'Auswählen / Anzeigen', tools: sectionAuswahlen },
{ label: 'Zeichnen', tools: sectionZeichnen },
{ label: 'Bearbeiten', tools: sectionBearbeiten },
{ label: 'Bestuhlung', tools: sectionBestuhlung },
];
const LeftSidebar: React.FC<LeftSidebarProps> = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect, onCollapse }) => {
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" onClick={onCollapse}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
</div>
{sections.map((section) => (
<div className="tool-section" key={section.label}>
<div className="tool-section-label">{section.label}</div>
<div className="tool-grid">
{section.tools.map((t) => (
<button
key={t.tool}
className={`tool-btn${activeTool === t.tool ? ' active' : ''}`}
data-tool={t.tool}
title={t.title}
onClick={() => onToolChange(t.tool)}
>
{t.svg}
<span className="tool-btn-label">{t.label}</span>
<span className="tool-btn-kbd">{t.kbd}</span>
</button>
))}
</div>
</div>
))}
{activeTool === 'seating-template' && onTemplateSelect && (
<div className="tool-section" key="templates">
<div className="tool-section-label">Vorlagen</div>
<select
className="template-dropdown"
value={selectedTemplate ?? ''}
onChange={(e) => onTemplateSelect(e.target.value || null)}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid var(--color-border)',
background: 'var(--color-bg-secondary)',
color: 'var(--color-text)',
fontSize: '13px',
cursor: 'pointer',
}}
>
<option value=""> Vorlage wählen </option>
{SEATING_TEMPLATES.map((t) => (
<option key={t.name} value={t.name}>
{t.name} ({t.description})
</option>
))}
</select>
{selectedTemplate && (
<div style={{ marginTop: '6px', fontSize: '12px', color: 'var(--color-text-faint)' }}>
Klicken Sie auf die Zeichenfläche, um die Vorlage zu platzieren.
</div>
)}
</div>
)}
<div className="leftbar-footer">
<button className="leftbar-footer-btn" title="Plugin hinzufügen" aria-label="Plugin hinzufügen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
</button>
<button className="leftbar-footer-btn" title="Werkzeugkasten anpassen" aria-label="Werkzeugkasten anpassen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button className="leftbar-footer-btn" title="Hilfe (F1)" aria-label="Hilfe">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</button>
</div>
</aside>
);
};
export default LeftSidebar;
+59
View File
@@ -0,0 +1,59 @@
import React from 'react';
import type { MobileDrawersProps, DrawerTab } from '../types/ui.types';
const drawerTabs: Array<{ id: DrawerTab; label: string; svg: React.ReactNode }> = [
{ id: 'tool', label: 'Wz', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> },
{ id: 'layer', label: 'Layer', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg> },
{ id: 'library', label: 'Lib', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg> },
{ id: 'ki', label: 'KI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/></svg> },
];
const MobileDrawers: React.FC<MobileDrawersProps> = ({
leftOpen, rightOpen, activeRightTab, onCloseLeft, onCloseRight, onRightTabChange,
}) => {
return (
<>
<div className={`scrim${(leftOpen || rightOpen) ? ' show' : ''}`} aria-hidden="true" onClick={() => { onCloseLeft(); onCloseRight(); }}></div>
<aside className={`drawer drawer-left${leftOpen ? ' open' : ''}`} aria-label="Werkzeugpalette" aria-hidden={!leftOpen}>
<div className="drawer-header">
<span className="drawer-title">Werkzeuge</span>
<button className="drawer-close" aria-label="Schließen" onClick={onCloseLeft}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div className="drawer-body" id="drawer-left-body">
{/* Filled by LeftSidebar content on mobile */}
</div>
</aside>
<aside className={`drawer drawer-right${rightOpen ? ' open' : ''}`} aria-label="Panel" aria-hidden={!rightOpen}>
<div className="drawer-header">
<span className="drawer-title" id="drawer-right-title">Werkzeug</span>
<button className="drawer-close" aria-label="Schließen" onClick={onCloseRight}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div className="drawer-tabs" id="drawer-right-tabs" role="tablist">
{drawerTabs.map((tab) => (
<button
key={tab.id}
className={`drawer-tab${activeRightTab === tab.id ? ' active' : ''}`}
data-drawer-tab={tab.id}
role="tab"
onClick={() => onRightTabChange(tab.id)}
>
{tab.svg}
<span>{tab.label}</span>
</button>
))}
</div>
<div className="drawer-body" id="drawer-right-body">
{/* Filled by panel content on mobile */}
</div>
</aside>
</>
);
};
export default MobileDrawers;
+84
View File
@@ -0,0 +1,84 @@
/**
* PluginManager UI component for managing plugins
*/
import React, { useState, useEffect } from 'react';
import { pluginRegistry } from '../plugins';
import type { PluginState } from '../plugins';
const categoryLabels: Record<string, string> = {
tools: 'Werkzeuge',
elements: 'Elemente',
'import-export': 'Import/Export',
theme: 'Theme',
other: 'Sonstige',
};
const categoryIcons: Record<string, React.ReactNode> = {
tools: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
elements: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>,
'import-export': <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>,
theme: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>,
other: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>,
};
const PluginManager: React.FC = () => {
const [states, setStates] = useState<PluginState[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
useEffect(() => {
const update = () => setStates(pluginRegistry.getStates());
update();
return pluginRegistry.subscribe(update);
}, []);
const handleToggle = (id: string) => {
pluginRegistry.toggle(id);
};
if (states.length === 0) {
return (
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--color-text-muted)' }}>
Keine Plugins installiert.
</div>
);
}
return (
<div className="plugin-manager">
<div className="plugin-manager-header">
<strong>Plugins</strong>
<span style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>{states.filter(s => s.enabled).length} aktiv · {states.length} gesamt</span>
</div>
{states.map((state) => (
<div key={state.manifest.id} className={`plugin-card ${state.enabled ? 'enabled' : ''} ${expandedId === state.manifest.id ? 'expanded' : ''}`}>
<div className="plugin-card-header" onClick={() => setExpandedId(expandedId === state.manifest.id ? null : state.manifest.id)}>
<div className="plugin-icon" style={{ color: state.enabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>
{categoryIcons[state.manifest.category] || categoryIcons.other}
</div>
<div className="plugin-info">
<div className="plugin-name">{state.manifest.name}</div>
<div className="plugin-meta">v{state.manifest.version} · {categoryLabels[state.manifest.category] || state.manifest.category}</div>
</div>
<button
className={`plugin-toggle ${state.enabled ? 'on' : 'off'}`}
onClick={(e) => { e.stopPropagation(); handleToggle(state.manifest.id); }}
aria-label={state.enabled ? 'Deaktivieren' : 'Aktivieren'}
role="switch"
aria-checked={state.enabled}
>
<span className="plugin-toggle-knob" />
</button>
</div>
{expandedId === state.manifest.id && (
<div className="plugin-card-body">
<p className="plugin-description">{state.manifest.description}</p>
<div className="plugin-author">Autor: {state.manifest.author}</div>
</div>
)}
</div>
))}
</div>
);
};
export default PluginManager;
@@ -1,46 +0,0 @@
// Plugin-Registry: Lädt und initialisiert Plugins
// Plugins werden über die API geladen und registrieren sich mit init(cadApp)
// cadApp bietet API: registerTool, registerMenuItem, addToSidebar, etc.
const registry = {
plugins: [],
cadApp: null,
init(cadAppInstance) {
this.cadApp = cadAppInstance;
this.loadPlugins();
},
async loadPlugins() {
try {
// Load manifest from backend
const response = await fetch('/api/plugins');
const pluginList = await response.json();
for (const plugin of pluginList) {
await this.loadPlugin(plugin);
}
} catch (err) {
console.log('Plugin system: No plugins found or API unavailable');
}
},
async loadPlugin(plugin) {
try {
// Dynamically import plugin code
const module = await import(`/api/plugins/${plugin.id}/code`);
if (typeof module.default === 'function') {
module.default(this.cadApp);
this.plugins.push({ id: plugin.id, name: plugin.name, version: plugin.version });
console.log(`Plugin loaded: ${plugin.name}`);
}
} catch (err) {
console.warn(`Failed to load plugin: ${plugin.name}`, err);
}
},
getLoadedPlugins() {
return this.plugins;
},
};
export default registry;
@@ -1,337 +0,0 @@
.print-preview-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.print-preview-modal {
background: var(--surface, #fff);
border-radius: 8px;
width: 90vw;
max-width: 1100px;
height: 85vh;
max-height: 800px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.print-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid var(--border, #e0e0e0);
flex-shrink: 0;
}
.print-preview-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text, #333);
}
.print-preview-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--text-secondary, #888);
padding: 4px 8px;
border-radius: 4px;
transition: background 0.15s;
}
.print-preview-close:hover {
background: var(--hover, rgba(0,0,0,0.06));
}
.print-preview-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Settings sidebar ────────────────────────────────────────── */
.print-preview-settings {
width: 280px;
flex-shrink: 0;
padding: 12px;
overflow-y: auto;
border-right: 1px solid var(--border, #e0e0e0);
display: flex;
flex-direction: column;
gap: 8px;
}
.print-settings-fieldset {
border: 1px solid var(--border, #e0e0e0);
border-radius: 4px;
padding: 8px;
margin: 0;
}
.print-settings-fieldset legend {
font-weight: 500;
font-size: 12px;
color: var(--text-secondary, #666);
padding: 0 4px;
}
.print-settings-label {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 6px;
font-size: 12px;
color: var(--text-secondary, #666);
}
.print-settings-checkbox-label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 13px;
color: var(--text, #333);
cursor: pointer;
}
.print-settings-input,
.print-settings-select,
.print-settings-text-input {
width: 100%;
padding: 4px 6px;
border: 1px solid var(--border, #ccc);
border-radius: 4px;
background: var(--background, #fff);
color: var(--text, #333);
font-size: 14px;
box-sizing: border-box;
}
.print-settings-input:focus,
.print-settings-select:focus,
.print-settings-text-input:focus {
outline: none;
border-color: var(--primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
.print-settings-select {
cursor: pointer;
}
/* ── Info box ────────────────────────────────────────────────── */
.print-preview-info {
background: var(--surface-hover, rgba(0,0,0,0.04));
border-radius: 4px;
padding: 8px;
font-size: 12px;
}
.print-info-row {
display: flex;
justify-content: space-between;
padding: 2px 0;
color: var(--text-secondary, #666);
}
.print-info-row span:last-child {
font-weight: 500;
color: var(--text, #333);
}
/* ── Preview canvas ──────────────────────────────────────────── */
.print-preview-canvas {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
overflow: auto;
background: var(--background, #f5f5f5);
}
.print-preview-page-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.print-preview-page-image {
max-width: 100%;
max-height: 60vh;
border: 1px solid var(--border, #ccc);
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.print-preview-page-label {
font-size: 13px;
color: var(--text-secondary, #666);
}
/* ── Navigation ──────────────────────────────────────────────── */
.print-preview-nav {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.print-preview-nav-btn {
padding: 6px 16px;
border: 1px solid var(--border, #ccc);
border-radius: 4px;
background: var(--surface, #fff);
color: var(--text, #333);
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.print-preview-nav-btn:hover:not(:disabled) {
background: var(--hover, rgba(0,0,0,0.06));
}
.print-preview-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.print-preview-nav-info {
font-size: 13px;
color: var(--text-secondary, #666);
min-width: 60px;
text-align: center;
}
/* ── Page grid ───────────────────────────────────────────────── */
.print-preview-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 12px;
max-width: 300px;
justify-content: center;
}
.print-preview-grid-cell {
width: 32px;
height: 32px;
border: 1px solid var(--border, #ccc);
border-radius: 4px;
background: var(--surface, #fff);
color: var(--text, #333);
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.print-preview-grid-cell:hover {
background: var(--hover, rgba(0,0,0,0.06));
}
.print-preview-grid-cell.active {
background: var(--primary, #2563eb);
color: #fff;
border-color: var(--primary, #2563eb);
}
/* ── Empty state ─────────────────────────────────────────────── */
.print-preview-empty {
color: var(--text-secondary, #888);
font-size: 14px;
text-align: center;
padding: 40px;
}
/* ── Footer ──────────────────────────────────────────────────── */
.print-preview-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border, #e0e0e0);
flex-shrink: 0;
}
.print-preview-btn {
padding: 8px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.15s;
}
.print-preview-btn-secondary {
background: var(--surface, #f0f0f0);
color: var(--text, #333);
}
.print-preview-btn-secondary:hover {
background: var(--hover, rgba(0,0,0,0.08));
}
.print-preview-btn-primary {
background: var(--primary, #2563eb);
color: #fff;
}
.print-preview-btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.print-preview-btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Responsive ──────────────────────────────────────────────── */
@media (max-width: 768px) {
.print-preview-modal {
width: 95vw;
height: 90vh;
}
.print-preview-body {
flex-direction: column;
}
.print-preview-settings {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border, #e0e0e0);
max-height: 200px;
}
.print-preview-page-image {
max-height: 40vh;
}
}
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
import type { PropertiesPanelProps } from '../types/ui.types';
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ selectedElement, layers, onUpdateProperty }) => {
const el = selectedElement;
const x = el ? String(el.x) : '0';
const y = el ? String(el.y) : '0';
const w = el ? String(el.width) : '0.5';
const h = el ? String(el.height) : '0.5';
const rot = el ? String(el.properties.rotation ?? 0) : '0';
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', parseFloat(e.target.value) || 0)} />
</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', parseFloat(e.target.value) || 0)} />
</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', parseFloat(e.target.value) || 0)} />
</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', parseFloat(e.target.value) || 0)} />
</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', parseFloat(e.target.value) || 0)} />
</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" value={el?.properties.lineType || 'solid'} 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" value={String(el?.properties.strokeWidth ?? '0.18')} 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" value={el?.layerId || ''} 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;
@@ -1,181 +0,0 @@
.properties-panel {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 8px);
padding: var(--spacing-sm, 8px);
font-family: var(--font-family, sans-serif);
font-size: var(--font-size-md, 14px);
color: var(--text, #333);
}
/* ── Empty state ─────────────────────────────────────────────── */
.properties-panel-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
padding: var(--spacing-lg, 24px);
text-align: center;
}
.properties-panel-empty-icon {
font-size: 48px;
margin-bottom: var(--spacing-md, 16px);
opacity: 0.5;
}
.properties-panel-empty-text {
color: var(--text-secondary, #888);
font-size: var(--font-size-sm, 12px);
line-height: 1.5;
max-width: 220px;
}
/* ── Header ──────────────────────────────────────────────────── */
.properties-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
background-color: var(--surface-hover, rgba(0, 0, 0, 0.04));
border-radius: var(--border-radius, 4px);
margin-bottom: var(--spacing-xs, 4px);
}
.properties-panel-type-badge {
font-weight: var(--font-weight-bold, 700);
font-size: var(--font-size-sm, 12px);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--primary, #2563eb);
}
.properties-panel-id {
font-size: var(--font-size-xs, 10px);
color: var(--text-secondary, #888);
font-family: monospace;
}
/* ── Fieldsets ───────────────────────────────────────────────── */
.properties-panel-fieldset {
border: 1px solid var(--border, #e0e0e0);
border-radius: var(--border-radius, 4px);
padding: var(--spacing-sm, 8px);
margin: 0;
}
.properties-panel-fieldset legend {
font-weight: var(--font-weight-medium, 500);
font-size: var(--font-size-sm, 12px);
color: var(--text-secondary, #666);
padding: 0 var(--spacing-xs, 4px);
}
/* ── Rows ────────────────────────────────────────────────────── */
.properties-panel-row {
display: flex;
gap: var(--spacing-sm, 8px);
}
.properties-panel-row > * {
flex: 1;
}
/* ── Labels ──────────────────────────────────────────────────── */
.properties-panel-label {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: var(--spacing-xs, 4px);
font-size: var(--font-size-sm, 12px);
color: var(--text-secondary, #666);
}
/* ── Inputs ──────────────────────────────────────────────────── */
.properties-panel-input,
.properties-panel-text-input,
.properties-panel-select {
width: 100%;
padding: 4px 6px;
border: 1px solid var(--border, #ccc);
border-radius: var(--border-radius, 4px);
background-color: var(--background, #fff);
color: var(--text, #333);
font-size: var(--font-size-md, 14px);
font-family: var(--font-family, sans-serif);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
box-sizing: border-box;
}
.properties-panel-input:focus,
.properties-panel-text-input:focus,
.properties-panel-select:focus {
outline: none;
border-color: var(--primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
.properties-panel-input[type="number"] {
-moz-appearance: textfield;
}
.properties-panel-input[type="number"]::-webkit-inner-spin-button,
.properties-panel-input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* ── Color inputs ────────────────────────────────────────────── */
.properties-panel-color-input {
width: 100%;
height: 28px;
padding: 2px;
border: 1px solid var(--border, #ccc);
border-radius: var(--border-radius, 4px);
background-color: var(--background, #fff);
cursor: pointer;
box-sizing: border-box;
}
.properties-panel-color-input::-webkit-color-swatch-wrapper {
padding: 0;
}
.properties-panel-color-input::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.properties-panel-text-input {
margin-top: 2px;
font-size: var(--font-size-xs, 11px);
font-family: monospace;
}
/* ── Select ──────────────────────────────────────────────────── */
.properties-panel-select {
cursor: pointer;
appearance: auto;
}
/* ── Responsive ──────────────────────────────────────────────── */
@media (max-width: 768px) {
.properties-panel {
padding: var(--spacing-xs, 4px);
}
.properties-panel-row {
flex-direction: column;
}
}
-114
View File
@@ -1,114 +0,0 @@
.ribbon-bar {
display: flex;
background-color: var(--surface);
border-bottom: var(--border-width) solid var(--border);
padding: var(--spacing-xs) 0;
min-height: 40px;
align-items: center;
}
.ribbon-tab {
display: flex;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
transition: background-color var(--transition-normal);
margin: 0 var(--spacing-xs);
}
.ribbon-tab:hover {
background-color: var(--hover);
}
.ribbon-tab.active {
background-color: var(--primary);
color: var(--background);
}
.tab-icon {
margin-right: var(--spacing-sm);
font-size: var(--font-size-lg);
}
.tab-label {
font-weight: var(--font-weight-medium);
}
/* ── Divider ─────────────────────────────────────────────────── */
.ribbon-divider {
width: 1px;
height: 24px;
background-color: var(--border);
margin: 0 var(--spacing-sm);
}
/* ── Dropdown ────────────────────────────────────────────────── */
.ribbon-dropdown-wrapper {
position: relative;
display: flex;
align-items: center;
}
.ribbon-dropdown {
position: absolute;
top: 100%;
left: var(--spacing-xs);
background-color: var(--surface);
border: var(--border-width) solid var(--border);
border-radius: var(--border-radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 120px;
padding: var(--spacing-xs) 0;
margin-top: var(--spacing-xs);
}
.ribbon-dropdown-item {
display: block;
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
cursor: pointer;
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
text-align: left;
transition: background-color var(--transition-normal);
}
.ribbon-dropdown-item:hover {
background-color: var(--hover);
}
/* ── Status ──────────────────────────────────────────────────── */
.ribbon-status {
margin-left: var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--text-secondary, #888);
font-family: var(--font-family);
}
/* Responsive design */
@media (max-width: 768px) {
.ribbon-tab {
padding: var(--spacing-sm);
}
.tab-label {
display: none;
}
.tab-icon {
margin-right: 0;
}
}
+264
View File
@@ -0,0 +1,264 @@
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">
{/* ===== START TAB ===== */}
{activeTab === 'start' && (
<>
<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>
</>
)}
{/* ===== INSERT TAB ===== */}
{activeTab === 'insert' && (
<>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Linie zeichnen" onClick={() => onAction('insert-line')}>
<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>
<span>Linie</span>
</button>
<button className="ribbon-btn" title="Rechteck zeichnen" onClick={() => onAction('insert-rect')}>
<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>
<span>Rechteck</span>
</button>
<button className="ribbon-btn" title="Kreis zeichnen" onClick={() => onAction('insert-circle')}>
<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>
<span>Kreis</span>
</button>
<button className="ribbon-btn" title="Text einfügen" onClick={() => onAction('insert-text')}>
<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>
<span>Text</span>
</button>
<button className="ribbon-btn" title="Freihand zeichnen" onClick={() => onAction('insert-freehand')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17s5-5 9-5 9 5 9 5"/><path d="M3 12s5-5 9-5 9 5 9 5"/></svg>
<span>Freihand</span>
</button>
</div>
<div className="ribbon-group-label">Formen</div>
</div>
<div className="ribbon-divider"></div>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<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>
<button className="ribbon-btn" title="Bild einfügen" onClick={() => onAction('insert-image')}>
<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>Bild</span>
</button>
</div>
<div className="ribbon-group-label">Objekt</div>
</div>
</>
)}
{/* ===== FORMAT TAB ===== */}
{activeTab === 'format' && (
<>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Linienstärke" onClick={() => onAction('format-stroke-width')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="12" x2="20" y2="12"/></svg>
<span>Linienstärke</span>
</button>
<button className="ribbon-btn" title="Linienfarbe" onClick={() => onAction('format-stroke-color')}>
<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"/><line x1="3" y1="12" x2="21" y2="12"/></svg>
<span>Linienfarbe</span>
</button>
<button className="ribbon-btn" title="Füllfarbe" onClick={() => onAction('format-fill-color')}>
<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"/><path d="M3 3l18 18"/></svg>
<span>Füllfarbe</span>
</button>
<button className="ribbon-btn" title="Linientyp" onClick={() => onAction('format-stroke-style')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="16" x2="20" y2="16" stroke-dasharray="4 4"/></svg>
<span>Linientyp</span>
</button>
</div>
<div className="ribbon-group-label">Stil</div>
</div>
<div className="ribbon-divider"></div>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Nach links ausrichten" onClick={() => onAction('format-align-left')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="4" x2="4" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="6" y="14" width="8" height="4"/></svg>
<span>Links</span>
</button>
<button className="ribbon-btn" title="Zentrieren" onClick={() => onAction('format-align-center')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="4" x2="12" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="8" y="14" width="8" height="4"/></svg>
<span>Zentriert</span>
</button>
<button className="ribbon-btn" title="Nach rechts ausrichten" onClick={() => onAction('format-align-right')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="20" y1="4" x2="20" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="10" y="14" width="8" height="4"/></svg>
<span>Rechts</span>
</button>
</div>
<div className="ribbon-group-label">Ausrichtung</div>
</div>
</>
)}
{/* ===== VIEW TAB ===== */}
{activeTab === 'view' && (
<>
<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>
</div>
<div className="ribbon-group-label">Ansicht</div>
</div>
</>
)}
{/* ===== TOOLS TAB ===== */}
{activeTab === 'tools' && (
<>
<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" 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>
</>
)}
{/* ===== KI TAB ===== */}
{activeTab === 'ki' && (
<>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<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 Copilot</span>
</button>
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Zeichnen" onClick={() => onAction('ki-draw')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<span>KI Zeichnen</span>
</button>
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Analysieren" onClick={() => onAction('ki-analyze')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 5c0 1.66 4 3 9 3s9-1.34 9-3-4-3-9-3-9 1.34-9 3"/></svg>
<span>KI Analysieren</span>
</button>
</div>
<div className="ribbon-group-label">Künstliche Intelligenz</div>
</div>
</>
)}
</div>
</nav>
);
};
export default RibbonBar;
+74
View File
@@ -0,0 +1,74 @@
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';
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 },
];
const RightSidebar: React.FC<RightSidebarProps> = ({
activePanel, onPanelChange, selectedElement, layers, blocks,
activeLayerId, onSelectLayer, onAddLayer, onToggleLayer,
onDeleteLayer, onRenameLayer, onDuplicateLayer, onToggleLock,
onReorder, onAddSubLayer,
onElementsDeleted, onToggleElementVisible,
elements,
onRenameBlock, onDuplicateBlock, onDeleteBlock, onSvgImport, onSaveGroupAsBlock,
onBlockCategoryChange, onBlockSearch, onDragBlock,
kiMessages, kiSuggestions, onKISend, onKISuggestionClick, kiLoading, onUpdateElement,
}) => {
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={(key, value) => {
if (!selectedElement || !onUpdateElement) return;
const updated = { ...selectedElement };
if (key === 'x' || key === 'y' || key === 'width' || key === 'height') {
(updated as any)[key] = value as number;
} else if (key === 'layerId') {
updated.layerId = value as string;
} else {
updated.properties = { ...updated.properties, [key]: value };
}
onUpdateElement(updated);
}} />}
</div>
<div className={`rightbar-panel${activePanel === 'layer' ? ' active' : ''}`} data-panel-content="layer">
{activePanel === 'layer' && <LayerPanel layers={layers} elements={elements} onElementsDeleted={onElementsDeleted} onToggleElementVisible={onToggleElementVisible} activeLayerId={activeLayerId} onSelectLayer={onSelectLayer ?? (() => {})} onAddLayer={onAddLayer ?? (() => {})} onToggleLayer={onToggleLayer ?? (() => {})} onDeleteLayer={onDeleteLayer} onRenameLayer={onRenameLayer} onDuplicateLayer={onDuplicateLayer} onToggleLock={onToggleLock} onReorder={onReorder} onAddSubLayer={onAddSubLayer} />}
</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>
</aside>
);
};
export default RightSidebar;
+189
View File
@@ -0,0 +1,189 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import PluginManager from './PluginManager';
export interface SettingsModalProps {
open: boolean;
onClose: () => void;
}
type SettingsTab = 'personal' | 'password' | 'language' | 'theme' | 'users' | 'plugins' | 'ai';
const SettingsModal: React.FC<SettingsModalProps> = ({ open, onClose }) => {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<SettingsTab>('personal');
const [displayName, setDisplayName] = useState(user?.name || '');
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [language, setLanguage] = useState('de');
const [themeMode, setThemeMode] = useState<'light' | 'dark'>('dark');
const [accentColor, setAccentColor] = useState('#2563eb');
const [aiProvider, setAiProvider] = useState('openrouter');
const [aiModel, setAiModel] = useState('gpt-4o');
const [aiApiKey, setAiApiKey] = useState('');
const [pwMessage, setPwMessage] = useState('');
if (!open) return null;
const isAdmin = user?.role === 'admin';
const initials = (user?.name || '?').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
const tabs: Array<{ id: SettingsTab; label: string; adminOnly?: boolean }> = [
{ id: 'personal', label: 'Persönlich' },
{ id: 'password', label: 'Passwort' },
{ id: 'language', label: 'Sprache' },
{ id: 'theme', label: 'Theme' },
{ id: 'users', label: 'Benutzer', adminOnly: true },
{ id: 'plugins', label: 'Plugins', adminOnly: true },
{ id: 'ai', label: 'KI' },
];
const visibleTabs = tabs.filter(t => !t.adminOnly || isAdmin);
const handlePasswordChange = () => {
if (!oldPassword || !newPassword || !confirmPassword) {
setPwMessage('Bitte alle Felder ausfüllen.');
return;
}
if (newPassword !== confirmPassword) {
setPwMessage('Neue Passwörter stimmen nicht überein.');
return;
}
if (newPassword.length < 6) {
setPwMessage('Passwort muss mindestens 6 Zeichen lang sein.');
return;
}
setPwMessage('Passwort erfolgreich geändert.');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
};
const renderTabContent = () => {
switch (activeTab) {
case 'personal':
return (
<div className="settings-tab-content">
<label className="settings-label">Anzeigename</label>
<input className="settings-input" type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
<label className="settings-label">E-Mail</label>
<input className="settings-input" type="email" value={user?.email || ''} readOnly style={{ opacity: 0.6 }} />
<label className="settings-label">Avatar</label>
<div className="settings-avatar-preview">{initials}</div>
</div>
);
case 'password':
return (
<div className="settings-tab-content">
<label className="settings-label">Altes Passwort</label>
<input className="settings-input" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
<label className="settings-label">Neues Passwort</label>
<input className="settings-input" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
<label className="settings-label">Passwort bestätigen</label>
<input className="settings-input" type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
{pwMessage && <div className="settings-message">{pwMessage}</div>}
<button className="settings-btn" onClick={handlePasswordChange}>Passwort ändern</button>
</div>
);
case 'language':
return (
<div className="settings-tab-content">
<label className="settings-label">Sprache</label>
<select className="settings-input" value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
);
case 'theme':
return (
<div className="settings-tab-content">
<label className="settings-label">Erscheinungsbild</label>
<div className="settings-toggle-group">
<button className={`settings-toggle-btn${themeMode === 'light' ? ' active' : ''}`} onClick={() => setThemeMode('light')}>Hell</button>
<button className={`settings-toggle-btn${themeMode === 'dark' ? ' active' : ''}`} onClick={() => setThemeMode('dark')}>Dunkel</button>
</div>
<label className="settings-label">Akzentfarbe</label>
<div className="settings-color-picker">
<input type="color" value={accentColor} onChange={(e) => setAccentColor(e.target.value)} />
<span className="settings-color-value">{accentColor}</span>
</div>
</div>
);
case 'users':
return (
<div className="settings-tab-content">
<div className="settings-users-header">
<strong>Benutzerverwaltung</strong>
<button className="settings-btn">Benutzer hinzufügen</button>
</div>
<div className="settings-user-list">
<div className="settings-user-row">
<div className="settings-user-avatar">LM</div>
<div className="settings-user-info">
<div className="settings-user-name">Leopold M.</div>
<div className="settings-user-email">admin@example.com</div>
</div>
<span className="settings-user-role">Admin</span>
<button className="settings-btn-danger">Löschen</button>
</div>
</div>
</div>
);
case 'plugins':
return (
<div className="settings-tab-content">
<PluginManager />
</div>
);
case 'ai':
return (
<div className="settings-tab-content">
<label className="settings-label">KI-Anbieter</label>
<select className="settings-input" value={aiProvider} onChange={(e) => setAiProvider(e.target.value)}>
<option value="openrouter">OpenRouter</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="local">Lokal (Ollama)</option>
</select>
<label className="settings-label">Modell</label>
<select className="settings-input" value={aiModel} onChange={(e) => setAiModel(e.target.value)}>
<option value="gpt-4o">GPT-4o</option>
<option value="gpt-4o-mini">GPT-4o mini</option>
<option value="claude-3.5-sonnet">Claude 3.5 Sonnet</option>
<option value="llama-3.3-70b">Llama 3.3 70B</option>
</select>
<label className="settings-label">API-Key</label>
<input className="settings-input" type="password" value={aiApiKey} onChange={(e) => setAiApiKey(e.target.value)} placeholder="••••••••••••" />
</div>
);
default:
return null;
}
};
return (
<div className="settings-modal-overlay" onClick={onClose}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<button className="settings-modal-close" onClick={onClose} aria-label="Schließen"></button>
<div className="settings-modal-tabs">
{visibleTabs.map((tab) => (
<button
key={tab.id}
className={`settings-modal-tab${activeTab === tab.id ? ' active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="settings-modal-content">
{renderTabContent()}
</div>
</div>
</div>
);
};
export default SettingsModal;
-95
View File
@@ -1,95 +0,0 @@
.side-panel {
display: flex;
flex-direction: column;
background-color: var(--surface);
border-left: var(--border-width) solid var(--border);
width: 300px;
height: 100%;
}
.panel-tabs {
display: flex;
flex-direction: column;
padding: var(--spacing-sm) 0;
background-color: var(--surface);
border-bottom: var(--border-width) solid var(--border);
}
.panel-tab {
display: flex;
align-items: center;
padding: var(--spacing-md);
background: transparent;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
transition: background-color var(--transition-normal);
text-align: left;
}
.panel-tab:hover {
background-color: var(--hover);
}
.panel-tab.active {
background-color: var(--primary);
color: var(--background);
}
.tab-icon {
margin-right: var(--spacing-md);
font-size: var(--font-size-lg);
}
.tab-label {
font-weight: var(--font-weight-medium);
}
.panel-content-container {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
}
.panel-content {
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
}
/* Responsive design */
@media (max-width: 1024px) {
.side-panel {
width: 250px;
}
}
@media (max-width: 768px) {
.side-panel {
width: 100%;
height: 300px;
border-left: none;
border-top: var(--border-width) solid var(--border);
}
.panel-tabs {
flex-direction: row;
overflow-x: auto;
}
.panel-tab {
flex: 1;
justify-content: center;
}
.tab-label {
display: none;
}
.tab-icon {
margin-right: 0;
}
}
-64
View File
@@ -1,64 +0,0 @@
.status-bar {
display: flex;
background-color: var(--surface);
border-top: var(--border-width) solid var(--border);
padding: var(--spacing-xs) var(--spacing-sm);
min-height: 30px;
align-items: center;
font-family: var(--font-family);
font-size: var(--font-size-sm);
color: var(--text);
}
.status-section {
display: flex;
align-items: center;
margin-right: var(--spacing-lg);
}
.status-label {
font-weight: var(--font-weight-medium);
margin-right: var(--spacing-xs);
}
.status-value {
font-weight: var(--font-weight-regular);
}
.toggle-button {
padding: var(--spacing-xs) var(--spacing-sm);
border: var(--border-width) solid var(--border);
border-radius: var(--border-radius);
background-color: var(--background);
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-sm);
cursor: pointer;
transition: background-color var(--transition-normal);
}
.toggle-button:hover {
background-color: var(--hover);
}
.toggle-button.enabled {
background-color: var(--primary);
color: var(--background);
}
.toggle-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive design */
@media (max-width: 768px) {
.status-bar {
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.status-section {
margin-right: 0;
}
}
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import type { StatusBarProps } from '../types/ui.types';
const StatusBar: React.FC<StatusBarProps> = ({
snapEnabled, orthoEnabled, polarEnabled, gridEnabled,
cursorX, cursorY, activeLayer, activeTool, onlineCount,
onToggleSnap, onToggleOrtho, onTogglePolar, onToggleGrid, seatCount,
}) => {
return (
<footer className="statusbar" role="status">
<div className={`status-item${snapEnabled ? ' active' : ''}`} title="Snap-Modus (F3)" aria-pressed={snapEnabled} onClick={onToggleSnap}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
<span>SNAP</span>
</div>
<div className={`status-item${orthoEnabled ? ' active' : ''}`} title="Ortho-Modus (F8)" aria-pressed={orthoEnabled} onClick={onToggleOrtho}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18M3 21l18-18"/></svg>
<span>ORTHO</span>
</div>
<div className={`status-item hide-mobile${polarEnabled ? ' active' : ''}`} title="Polar-Tracking" aria-pressed={polarEnabled} onClick={onTogglePolar}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="2" x2="12" y2="16"/></svg>
<span>POLAR</span>
</div>
<div className={`status-item${gridEnabled ? ' active' : ''}`} title="Grid anzeigen (F7)" aria-pressed={gridEnabled} onClick={onToggleGrid}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<span>GRID</span>
</div>
<div className="status-item" style={{ fontFamily: 'var(--font-mono)' }}>
<span>X: {cursorX.toFixed(3)} m</span>
</div>
<div className="status-item" style={{ fontFamily: 'var(--font-mono)' }}>
<span>Y: {cursorY.toFixed(3)} m</span>
</div>
<div className="status-item hide-mobile" title="Layer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/></svg>
<span>{activeLayer}</span>
</div>
<div className="status-item hide-mobile" title="Werkzeug">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
<span>{activeTool}</span>
</div>
{seatCount !== undefined && seatCount > 0 && (
<div className="status-item hide-mobile" title="Stuhl-Anzahl" style={{ fontFamily: 'var(--font-mono)' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="3" width="12" height="18" rx="1"/><line x1="6" y1="7" x2="18" y2="7"/></svg>
<span>{seatCount} Stühle</span>
</div>
)}
<div className="status-item" title={`${onlineCount} anderer Nutzer online`}>
<span className="status-dot"></span>
<span>Online · {onlineCount} weiterer</span>
</div>
</footer>
);
};
export default StatusBar;
-42
View File
@@ -1,42 +0,0 @@
import React from 'react';
const tools = [
{ id: 'select', label: '↖', title: 'Auswahl' },
{ id: 'line', label: '╲', title: 'Linie' },
{ id: 'rect', label: '▭', title: 'Rechteck' },
{ id: 'circle', label: '○', title: 'Kreis' },
{ id: 'freehand', label: '✎', title: 'Freihand' },
{ id: 'text', label: 'T', title: 'Text' },
{ id: 'dimension', label: '↔', title: 'Bemaßung' },
];
const Toolbar = ({ activeTool, setActiveTool }) => {
return (
<>
{tools.map(tool => (
<button
key={tool.id}
title={tool.title}
onClick={() => setActiveTool(tool.id)}
style={{
width: 36,
height: 36,
fontSize: 16,
background: activeTool === tool.id ? '#1890ff' : '#34495e',
color: 'white',
border: 'none',
borderRadius: 4,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{tool.label}
</button>
))}
</>
);
};
export default Toolbar;
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import type { TopbarProps } from '../types/ui.types';
const Topbar: React.FC<TopbarProps> = ({ projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme, onOpenSettings }) => {
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>
<button className="icon-btn-top" aria-label="Einstellungen" title="Einstellungen" onClick={onOpenSettings}>
<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>
<div className="avatar" aria-label="Benutzer Leopold M." title="Leopold M.">LM</div>
</div>
</header>
);
};
export default Topbar;
+149
View File
@@ -0,0 +1,149 @@
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;
onReorder?: (draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => 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,
onReorder,
renderIcon,
renderActions,
renderDetail,
draggable = false,
}) => {
const [expandedSet, setExpandedSet] = useState<Set<string>>(new Set());
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [dragPosition, setDragPosition] = useState<'before' | 'after' | 'inside'>('before');
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 handleDragStart = (e: React.DragEvent, id: string) => {
if (!draggable || !onReorder) return;
setDraggedId(id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
};
const handleDragOver = (e: React.DragEvent, id: string) => {
if (!draggable || !onReorder || !draggedId || draggedId === id) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const offset = e.clientY - rect.top;
const third = rect.height / 3;
if (offset < third) {
setDragPosition('before');
} else if (offset > third * 2) {
setDragPosition('after');
} else {
setDragPosition('inside');
}
setDragOverId(id);
};
const handleDragLeave = (_e: React.DragEvent, id: string) => {
if (!draggable || !onReorder) return;
if (dragOverId === id) {
setDragOverId(null);
}
};
const handleDrop = (e: React.DragEvent, id: string) => {
if (!draggable || !onReorder || !draggedId || draggedId === id) return;
e.preventDefault();
e.stopPropagation();
onReorder(draggedId, id, dragPosition);
setDraggedId(null);
setDragOverId(null);
};
const handleDragEnd = () => {
setDraggedId(null);
setDragOverId(null);
};
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;
const isDragOver = dragOverId === node.id;
const isDragging = draggedId === node.id;
let dragClass = '';
if (isDragOver && dragPosition === 'before') dragClass = ' tree-drag-before';
if (isDragOver && dragPosition === 'after') dragClass = ' tree-drag-after';
if (isDragOver && dragPosition === 'inside') dragClass = ' tree-drag-inside';
return (
<React.Fragment key={node.id}>
<div
className={`tree-node${isSelected ? ' active' : ''}${dragClass}${isDragging ? ' tree-dragging' : ''}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => onSelect(node.id)}
draggable={draggable}
onDragStart={(e) => handleDragStart(e, node.id)}
onDragOver={(e) => handleDragOver(e, node.id)}
onDragLeave={(e) => handleDragLeave(e, node.id)}
onDrop={(e) => handleDrop(e, node.id)}
onDragEnd={handleDragEnd}
>
{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;
-51
View File
@@ -1,51 +0,0 @@
import React, { createContext, useState, useEffect, useContext } from 'react';
import api from '../services/api';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
api.get('/auth/me').then((res) => {
setUser(res.data);
}).catch(() => {
localStorage.removeItem('token');
}).finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (email, password) => {
const res = await api.post('/auth/login', { email, password });
const { token, user } = res.data;
localStorage.setItem('token', token);
setUser(user);
return user;
};
const register = async (email, password) => {
const res = await api.post('/auth/register', { email, password });
const { token, user } = res.data;
localStorage.setItem('token', token);
setUser(user);
return user;
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
+125
View File
@@ -0,0 +1,125 @@
/**
* AuthContext Frontend authentication state management
*/
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
const API_BASE = import.meta.env.VITE_API_BASE || '';
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;
}
+121
View File
@@ -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();
}
}
}
+144
View File
@@ -0,0 +1,144 @@
/**
* 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)}`;
try {
this.ws = new WebSocket(fullUrl);
} catch (err) {
console.warn('[WebSocketProvider] Failed to construct WebSocket:', err);
this.setStatus('error');
this.scheduleReconnect();
return;
}
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);
}
}
}
+131
View File
@@ -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();
}
}
+7
View File
@@ -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';
+179
View File
@@ -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 = `wss://${window.location.host}`;
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,
};
}
+230
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
export { HistoryManager, getDefaultHistoryManager } from './HistoryManager';
export type { CADStateSnapshot, HistoryEntry, HistoryManagerOptions } from './HistoryManager';
-8
View File
@@ -1,8 +0,0 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
}
File diff suppressed because it is too large Load Diff
-10
View File
@@ -1,10 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+17
View File
@@ -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>,
);
-72
View File
@@ -1,72 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../services/api';
import { useAuth } from '../contexts/AuthContext';
const Dashboard = () => {
const [drawings, setDrawings] = useState([]);
const [loading, setLoading] = useState(true);
const { user, logout } = useAuth();
const navigate = useNavigate();
useEffect(() => {
api.get('/drawings').then(res => {
setDrawings(res.data);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const handleCreate = async () => {
try {
const res = await api.post('/drawings', { name: 'Neue Zeichnung' });
navigate(`/editor/${res.data.id}`);
} catch (err) {
alert('Fehler beim Erstellen');
}
};
const handleDelete = async (id) => {
if (!confirm('Zeichnung wirklich löschen?')) return;
try {
await api.delete(`/drawings/${id}`);
setDrawings(drawings.filter(d => d.id !== id));
} catch (err) {
alert('Fehler beim Löschen');
}
};
if (loading) return <div style={{ padding: 20 }}>Lade...</div>;
return (
<div style={{ maxWidth: 800, margin: '20px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h2>Meine Zeichnungen ({user?.email})</h2>
<button onClick={logout} style={{ padding: '8px 16px', backgroundColor: '#ff4d4f', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Abmelden</button>
</div>
<button onClick={handleCreate} style={{ padding: '10px 20px', backgroundColor: '#1890ff', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer', marginBottom: 20 }}>Neue Zeichnung</button>
{drawings.length === 0 ? (
<p>Keine Zeichnungen vorhanden.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr><th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #ddd' }}>Name</th><th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #ddd' }}>Zuletzt geändert</th><th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #ddd' }}>Aktionen</th></tr>
</thead>
<tbody>
{drawings.map(d => (
<tr key={d.id}>
<td style={{ padding: 8, borderBottom: '1px solid #eee' }}>{d.name}</td>
<td style={{ padding: 8, borderBottom: '1px solid #eee' }}>{new Date(d.updatedAt).toLocaleString()}</td>
<td style={{ padding: 8, borderBottom: '1px solid #eee' }}>
<button onClick={() => navigate(`/editor/${d.id}`)} style={{ marginRight: 8, padding: '4px 12px', backgroundColor: '#52c41a', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Öffnen</button>
<button onClick={() => handleDelete(d.id)} style={{ padding: '4px 12px', backgroundColor: '#ff4d4f', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Löschen</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
export default Dashboard;
+150
View File
@@ -0,0 +1,150 @@
/**
* Dashboard Page Project overview after login
*/
import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_BASE || '';
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>
);
}
-238
View File
@@ -1,238 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../services/api';
import CADCanvas from '../components/CADCanvas';
import Toolbar from '../components/Toolbar';
import LayerPanel from '../components/LayerPanel';
import BlockLibrary from '../components/BlockLibrary';
import pluginRegistry from '../components/PluginRegistry';
const Editor = () => {
const { id } = useParams();
const navigate = useNavigate();
const canvasRef = useRef(null);
const [drawing, setDrawing] = useState(null);
const [activeTool, setActiveTool] = useState('select');
const [layers, setLayers] = useState([
{ name: 'Layer 0', visible: true, locked: false },
]);
const [activeLayer, setActiveLayer] = useState('Layer 0');
const [showLayers, setShowLayers] = useState(true);
const [showBlocks, setShowBlocks] = useState(true);
const [saveStatus, setSaveStatus] = useState('');
const fileInputRef = useRef(null);
// Load drawing
useEffect(() => {
if (id && id !== 'new') {
api.get(`/drawings/${id}`).then(res => {
setDrawing(res.data);
if (res.data.canvas_json) {
try {
const json = JSON.parse(res.data.canvas_json);
canvasRef.current?.loadFromJSON?.(json);
} catch (e) {
console.warn('Canvas JSON parse error, starting fresh');
}
}
}).catch(err => {
console.error(err);
navigate('/dashboard');
});
} else {
api.post('/drawings', { name: 'Neue Zeichnung' }).then(res => {
setDrawing(res.data);
navigate(`/editor/${res.data.id}`, { replace: true });
}).catch(err => {
console.error(err);
navigate('/dashboard');
});
}
}, [id]);
// Initialize plugin registry when canvas is ready
const handleCanvasReady = (canvas) => {
const cadApp = {
getCanvas: () => canvas,
getApi: () => api,
getDrawing: () => drawing,
registerTool: (id, label, icon, handler) => {
console.log(`Plugin registered tool: ${id}`);
// Would integrate into toolbar dynamically
},
};
pluginRegistry.init(cadApp);
};
// Save canvas JSON to backend
const saveCanvas = async () => {
if (!drawing || !canvasRef.current) return;
try {
setSaveStatus('Speichere...');
const json = canvasRef.current.toJSON();
await api.put(`/drawings/${drawing.id}`, {
canvas_json: JSON.stringify(json),
name: drawing.name,
});
setSaveStatus('Gespeichert ✓');
setTimeout(() => setSaveStatus(''), 2000);
} catch (err) {
setSaveStatus('Fehler ✗');
console.error(err);
}
};
// DXF Import
const handleDxfImport = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const formData = new FormData();
formData.append('file', file);
const res = await api.post('/dxf/import', formData);
if (res.data?.objects && canvasRef.current) {
canvasRef.current.addObjects(res.data.objects);
setSaveStatus('DXF importiert ✓');
setTimeout(() => setSaveStatus(''), 2000);
}
e.target.value = '';
} catch (err) {
console.error('DXF import error:', err);
setSaveStatus('DXF-Import Fehler ✗');
setTimeout(() => setSaveStatus(''), 3000);
e.target.value = '';
}
};
// DXF Export
const handleDxfExport = async () => {
if (!canvasRef.current) return;
try {
const json = canvasRef.current.toJSON();
const res = await api.post('/dxf/export', { objects: json.objects || [] }, {
responseType: 'blob',
});
const blob = new Blob([res.data], { type: 'application/dxf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (drawing?.name || 'zeichnung') + '.dxf';
a.click();
URL.revokeObjectURL(url);
setSaveStatus('DXF exportiert ✓');
setTimeout(() => setSaveStatus(''), 2000);
} catch (err) {
console.error('DXF export error:', err);
setSaveStatus('DXF-Export Fehler ✗');
setTimeout(() => setSaveStatus(''), 3000);
}
};
// SVG Export (client-side)
const handleSvgExport = () => {
if (!canvasRef.current) return;
const canvas = canvasRef.current.getCanvas();
if (!canvas) return;
try {
const svg = canvas.toSVG();
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (drawing?.name || 'zeichnung') + '.svg';
a.click();
URL.revokeObjectURL(url);
setSaveStatus('SVG exportiert ✓');
setTimeout(() => setSaveStatus(''), 2000);
} catch (err) {
console.error('SVG export error:', err);
}
};
const handleDelete = () => {
if (!confirm('Ausgewähltes Objekt löschen?')) return;
const canvas = canvasRef.current?.getCanvas();
if (canvas) {
const active = canvas.getActiveObject();
if (active) {
canvas.remove(active);
canvas.renderAll();
}
}
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
saveCanvas();
} else if (e.key === 'Delete' || e.key === 'Backspace') {
handleDelete();
} else if (e.ctrlKey && e.key === 'z') {
// Undo would need command stack
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [drawing]);
return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
{/* Toolbar */}
<div style={{ width: 50, background: '#2c3e50', color: 'white', display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 10, gap: 10, zIndex: 20 }}>
<Toolbar activeTool={activeTool} setActiveTool={setActiveTool} canvasRef={canvasRef} />
<button
onClick={handleDelete}
title="Löschen"
style={{ width: 36, height: 36, background: '#c0392b', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer', marginTop: 'auto', marginBottom: 10 }}
>
🗑
</button>
</div>
{/* Canvas area */}
<div style={{ flex: 1, position: 'relative', background: '#f0f0f0' }}>
<div style={{ position: 'absolute', top: 10, left: 10, zIndex: 10, display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button onClick={() => navigate('/dashboard')} style={{ padding: '4px 12px', background: '#1890ff', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}> Zurück</button>
<button onClick={saveCanvas} style={{ padding: '4px 12px', background: '#52c41a', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Speichern</button>
<button onClick={() => fileInputRef.current?.click()} style={{ padding: '4px 12px', background: '#faad14', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>DXF Import</button>
<button onClick={handleDxfExport} style={{ padding: '4px 12px', background: '#722ed1', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>DXF Export</button>
<button onClick={handleSvgExport} style={{ padding: '4px 12px', background: '#13c2c2', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>SVG Export</button>
<span style={{ padding: '4px 12px', background: 'white', borderRadius: 4, fontSize: 14 }}>{saveStatus}</span>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleDxfImport}
accept=".dxf"
style={{ display: 'none' }}
/>
<CADCanvas
ref={canvasRef}
activeTool={activeTool}
setActiveTool={setActiveTool}
onCanvasReady={handleCanvasReady}
/>
</div>
{/* Right panels */}
<div style={{ width: 250, background: '#fafafa', borderLeft: '1px solid #ddd', overflowY: 'auto' }}>
<div style={{ padding: 10, borderBottom: '1px solid #ddd', cursor: 'pointer', fontWeight: 'bold', userSelect: 'none' }} onClick={() => setShowLayers(!showLayers)}>
Layer {showLayers ? '▲' : '▼'}
</div>
{showLayers && (
<LayerPanel layers={layers} setLayers={setLayers} activeLayer={activeLayer} setActiveLayer={setActiveLayer} />
)}
<div style={{ padding: 10, borderBottom: '1px solid #ddd', cursor: 'pointer', fontWeight: 'bold', userSelect: 'none' }} onClick={() => setShowBlocks(!showBlocks)}>
Teilebibliothek {showBlocks ? '▲' : '▼'}
</div>
{showBlocks && (
<BlockLibrary canvasRef={canvasRef} />
)}
</div>
</div>
);
};
export default Editor;
-45
View File
@@ -1,45 +0,0 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
await login(email, password);
navigate('/dashboard');
} catch (err) {
setError(err.response?.data?.error || 'Login fehlgeschlagen');
}
};
return (
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20, background: 'white', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
<h2>Web-CAD Anmeldung</h2>
{error && <p style={{ color: 'red' }}>{error}</p>}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 12 }}>
<label>E-Mail</label><br />
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
</div>
<div style={{ marginBottom: 16 }}>
<label>Passwort</label><br />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
</div>
<button type="submit" style={{ width: '100%', padding: 10, backgroundColor: '#1890ff', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Anmelden</button>
</form>
<p style={{ marginTop: 12, textAlign: 'center' }}>
Kein Konto? <Link to="/register">Registrieren</Link>
</p>
</div>
);
};
export default Login;
+77
View File
@@ -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>
);
}
-54
View File
@@ -1,54 +0,0 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const Register = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [error, setError] = useState('');
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== passwordConfirm) {
setError('Passwörter stimmen nicht überein');
return;
}
try {
await register(email, password);
navigate('/dashboard');
} catch (err) {
setError(err.response?.data?.error || 'Registrierung fehlgeschlagen');
}
};
return (
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20, background: 'white', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
<h2>Registrierung</h2>
{error && <p style={{ color: 'red' }}>{error}</p>}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 12 }}>
<label>E-Mail</label><br />
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
</div>
<div style={{ marginBottom: 12 }}>
<label>Passwort</label><br />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
</div>
<div style={{ marginBottom: 16 }}>
<label>Passwort bestätigen</label><br />
<input type="password" value={passwordConfirm} onChange={(e) => setPasswordConfirm(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
</div>
<button type="submit" style={{ width: '100%', padding: 10, backgroundColor: '#52c41a', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Registrieren</button>
</form>
<p style={{ marginTop: 12, textAlign: 'center' }}>
Bereits registriert? <Link to="/login">Anmelden</Link>
</p>
</div>
);
};
export default Register;
+117
View File
@@ -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>
);
}
+170
View File
@@ -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();
+221
View File
@@ -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');
},
};
+26
View File
@@ -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);
}
+118
View File
@@ -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;
}
-16
View File
@@ -1,16 +0,0 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
});
// Add auth token to every request if available
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;
+420
View File
@@ -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 || '';
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();
}
+249
View File
@@ -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;
});
}
}
-56
View File
@@ -1,56 +0,0 @@
import api from './api';
// Get all blocks for the current project
export const getBlocks = async () => {
try {
const response = await api.get('/blocks');
return response.data || [];
} catch (error) {
console.error('Error fetching blocks:', error);
throw error;
}
};
// Create a new block definition
export const createBlock = async (blockData) => {
try {
const response = await api.post('/blocks', blockData);
return response.data;
} catch (error) {
console.error('Error creating block:', error);
throw error;
}
};
// Update an existing block definition
export const updateBlock = async (blockId, blockData) => {
try {
const response = await api.put(`/blocks/${blockId}`, blockData);
return response.data;
} catch (error) {
console.error('Error updating block:', error);
throw error;
}
};
// Delete a block definition
export const deleteBlock = async (blockId) => {
try {
await api.delete(`/blocks/${blockId}`);
return true;
} catch (error) {
console.error('Error deleting block:', error);
throw error;
}
};
// Insert a block instance on the canvas
export const insertBlockInstance = async (blockId, position) => {
try {
const response = await api.post(`/blocks/${blockId}/insert`, position);
return response.data;
} catch (error) {
console.error('Error inserting block instance:', error);
throw error;
}
};
+300
View File
@@ -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 } },
],
},
];
}
+153
View File
@@ -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;
}
+239
View File
@@ -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}`;
}
}
+186
View File
@@ -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';
}
+214
View File
@@ -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;
}
+287
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* 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);
}
+211
View File
@@ -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)}`,
};
}
}
+174
View File
@@ -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);
}
+402
View File
@@ -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
+313
View File
@@ -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);
}
-71
View File
@@ -1,71 +0,0 @@
:root {
/* Light theme colors */
--background-light: #ffffff;
--surface-light: #f5f5f5;
--primary-light: #1a73e8;
--secondary-light: #5f6368;
--text-light: #202124;
--border-light: #dadce0;
--hover-light: #f1f3f4;
/* Dark theme colors */
--background-dark: #121212;
--surface-dark: #1e1e1e;
--primary-dark: #8ab4f8;
--secondary-dark: #9aa0a6;
--text-dark: #e8eaed;
--border-dark: #5f6368;
--hover-dark: #272727;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* Fonts */
--font-family: 'Roboto', 'Arial', sans-serif;
--font-size-sm: 12px;
--font-size-md: 14px;
--font-size-lg: 16px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
/* Borders */
--border-radius: 4px;
--border-width: 1px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
/* Transitions */
--transition-fast: 0.1s ease;
--transition-normal: 0.2s ease;
--transition-slow: 0.3s ease;
}
/* Light theme */
[data-theme="light"] {
--background: var(--background-light);
--surface: var(--surface-light);
--primary: var(--primary-light);
--secondary: var(--secondary-light);
--text: var(--text-light);
--border: var(--border-light);
--hover: var(--hover-light);
}
/* Dark theme */
[data-theme="dark"] {
--background: var(--background-dark);
--surface: var(--surface-dark);
--primary: var(--primary-dark);
--secondary: var(--secondary-dark);
--text: var(--text-dark);
--border: var(--border-dark);
--hover: var(--hover-dark);
}
View File
View File
@@ -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);
}
}
}
+350
View File
@@ -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;
}
+112
View File
@@ -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';
+244
View File
@@ -0,0 +1,244 @@
/**
* 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';
export type Theme = 'light' | 'dark';
export type DrawerTab = 'tool' | 'layer' | 'library' | 'ki';
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;
onOpenSettings?: () => void;
}
export interface SettingsModalProps {
open: boolean;
onClose: () => void;
}
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;
onCollapse?: () => 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[];
activeLayerId?: string;
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[];
elements?: CADElement[];
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;
onReorder?: (draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => void;
onUpdateLayerLineType?: (id: string, lineType: 'solid' | 'dashed' | 'dotted') => void;
onUpdateLayerTransparency?: (id: string, transparency: number) => void;
onAddSubLayer?: (parentId: string) => void;
onElementsDeleted?: (ids: string[]) => void;
onToggleElementVisible?: (id: 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;
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;
onUpdateElement?: (el: CADElement) => void;
}
export interface PropertiesPanelProps {
selectedElement: CADElement | null;
layers: CADLayer[];
onUpdateProperty: (key: string, value: unknown) => void;
}
export interface LayerPanelProps {
layers: CADLayer[];
elements?: CADElement[];
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;
onReorder?: (draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => void;
onUpdateLayerLineType?: (id: string, lineType: 'solid' | 'dashed' | 'dotted') => void;
onUpdateLayerTransparency?: (id: string, transparency: number) => void;
onAddSubLayer?: (parentId: string) => void;
onElementsDeleted?: (ids: string[]) => void;
onToggleElementVisible?: (id: string) => 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;
onReorder?: (draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => void;
draggable?: boolean;
renderIcon?: (node: TreeNode) => ReactNode;
}
export interface MobileDrawersProps {
leftOpen: boolean;
rightOpen: boolean;
activeRightTab: DrawerTab;
onCloseLeft: () => void;
onCloseRight: () => void;
onRightTabChange: (tab: DrawerTab) => void;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />

Some files were not shown because too many files have changed in this diff Show More