From 2109f0544bd63375758b06a8b3ebffcbedba24af Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 28 Jun 2026 10:00:32 +0200 Subject: [PATCH] Replace frontend with real working CAD editor source from /data/web-cad-neu (TS, React 18, full ribbon bar, KI, layers, tools) --- frontend/.dockerignore | 5 + frontend/.gitignore | 24 - frontend/Dockerfile | 16 +- frontend/README.md | 16 - frontend/eslint.config.js | 21 - frontend/index.html | 23 +- frontend/nginx.conf | 39 +- frontend/package-lock.json | 4903 ++++++++--------- frontend/package.json | 44 +- frontend/public/favicon.svg | 1 - frontend/public/icons.svg | 24 - frontend/src/App.css | 184 - frontend/src/App.jsx | 35 - frontend/src/App.tsx | 1089 ++++ frontend/src/App.tsx.bak | 1076 ++++ frontend/src/assets/hero.png | Bin 13057 -> 0 bytes frontend/src/assets/react.svg | 1 - frontend/src/assets/vite.svg | 1 - frontend/src/canvas/LayerManager.ts | 195 + frontend/src/canvas/RenderEngine.ts | 985 ++++ frontend/src/canvas/SelectionEngine.ts | 297 + frontend/src/canvas/SnapEngine.ts | 402 ++ frontend/src/canvas/SpatialIndex.ts | 48 + frontend/src/canvas/ZoomPanController.ts | 99 + frontend/src/components/BackgroundImport.tsx | 267 + frontend/src/components/BlockLibrary.jsx | 104 - frontend/src/components/BlockLibrary.tsx | 141 + frontend/src/components/CADCanvas.jsx | 253 - frontend/src/components/CanvasArea.css | 26 - frontend/src/components/CanvasArea.tsx | 357 ++ frontend/src/components/CommandLine.css | 111 - frontend/src/components/CommandLine.tsx | 253 + frontend/src/components/HistoryPanel.tsx | 77 + frontend/src/components/KICopilot.tsx | 88 + frontend/src/components/LayerPanel.jsx | 97 - frontend/src/components/LayerPanel.tsx | 209 + frontend/src/components/LeftSidebar.tsx | 138 + frontend/src/components/MobileDrawers.tsx | 59 + frontend/src/components/PluginManager.tsx | 84 + frontend/src/components/PluginRegistry.jsx | 46 - .../components/PrintPreview/PrintPreview.css | 337 -- frontend/src/components/PropertiesPanel.tsx | 110 + .../PropertiesPanel/PropertiesPanel.css | 181 - frontend/src/components/RibbonBar.css | 114 - frontend/src/components/RibbonBar.tsx | 264 + frontend/src/components/RightSidebar.tsx | 74 + frontend/src/components/SettingsModal.tsx | 189 + frontend/src/components/SidePanel.css | 95 - frontend/src/components/StatusBar.css | 64 - frontend/src/components/StatusBar.tsx | 55 + frontend/src/components/Toolbar.jsx | 42 - frontend/src/components/Topbar.tsx | 61 + frontend/src/components/TreeView.tsx | 149 + frontend/src/contexts/AuthContext.jsx | 51 - frontend/src/contexts/AuthContext.tsx | 125 + frontend/src/crdt/AwarenessManager.ts | 121 + frontend/src/crdt/WebSocketProvider.ts | 144 + frontend/src/crdt/YjsDocument.ts | 131 + frontend/src/crdt/index.ts | 7 + frontend/src/crdt/useYjsBinding.ts | 179 + frontend/src/history/HistoryManager.ts | 230 + frontend/src/history/index.ts | 2 + frontend/src/index.css | 8 - frontend/src/interaction/index.ts | 1036 ++++ frontend/src/main.jsx | 10 - frontend/src/main.tsx | 17 + frontend/src/pages/Dashboard.jsx | 72 - frontend/src/pages/Dashboard.tsx | 150 + frontend/src/pages/Editor.jsx | 238 - frontend/src/pages/Login.jsx | 45 - frontend/src/pages/Login.tsx | 77 + frontend/src/pages/Register.jsx | 54 - frontend/src/pages/Register.tsx | 117 + frontend/src/plugins/PluginRegistry.ts | 170 + frontend/src/plugins/builtin/eventTools.ts | 221 + frontend/src/plugins/index.ts | 26 + frontend/src/plugins/types.ts | 118 + frontend/src/services/api.js | 16 - frontend/src/services/api.ts | 420 ++ frontend/src/services/backgroundService.ts | 249 + frontend/src/services/blockService.js | 56 - frontend/src/services/blockService.ts | 300 + frontend/src/services/commandRegistry.ts | 153 + frontend/src/services/dimensionService.ts | 239 + frontend/src/services/dxfParser.ts | 186 + frontend/src/services/dxfWriter.ts | 214 + frontend/src/services/exportService.ts | 287 + frontend/src/services/importService.ts | 211 + frontend/src/services/pdfExport.ts | 174 + frontend/src/services/seatingService.ts | 402 ++ frontend/src/styles.css | 2012 +++++++ frontend/src/styles/auth.css | 313 ++ frontend/src/styles/theme.css | 71 - frontend/src/tools/.gitkeep | 0 frontend/src/tools/drawing/.gitkeep | 0 frontend/src/tools/modification/GroupTool.ts | 133 + frontend/src/tools/modification/geometry.ts | 350 ++ frontend/src/types/cad.types.ts | 112 + frontend/src/types/ui.types.ts | 244 + frontend/src/vite-env.d.ts | 1 + frontend/tests/Components.test.tsx | 274 + frontend/tests/GroupTool.test.ts | 190 + frontend/tests/HistoryManager.test.ts | 388 ++ frontend/tests/IntegrationWorkflow.test.ts | 911 +++ frontend/tests/LayerManager.test.ts | 346 ++ frontend/tests/RenderEngine.test.ts | 352 ++ frontend/tests/SelectionEngine.test.ts | 394 ++ frontend/tests/SnapEngine.test.ts | 340 ++ frontend/tests/SpatialIndex.test.ts | 153 + frontend/tests/StressTest.test.ts | 173 + frontend/tests/ZoomPanController.test.ts | 180 + frontend/tests/commandRegistry.test.ts | 259 + frontend/tests/geometry.test.ts | 295 + frontend/tests/setup.ts | 93 + frontend/tsconfig.json | 20 + frontend/{vite.config.js => vite.config.ts} | 10 +- frontend/vitest.config.ts | 22 + 117 files changed, 22344 insertions(+), 5121 deletions(-) create mode 100644 frontend/.dockerignore delete mode 100644 frontend/.gitignore delete mode 100644 frontend/README.md delete mode 100644 frontend/eslint.config.js delete mode 100644 frontend/public/favicon.svg delete mode 100644 frontend/public/icons.svg delete mode 100644 frontend/src/App.css delete mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/App.tsx.bak delete mode 100644 frontend/src/assets/hero.png delete mode 100644 frontend/src/assets/react.svg delete mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/canvas/LayerManager.ts create mode 100644 frontend/src/canvas/RenderEngine.ts create mode 100644 frontend/src/canvas/SelectionEngine.ts create mode 100644 frontend/src/canvas/SnapEngine.ts create mode 100644 frontend/src/canvas/SpatialIndex.ts create mode 100644 frontend/src/canvas/ZoomPanController.ts create mode 100644 frontend/src/components/BackgroundImport.tsx delete mode 100644 frontend/src/components/BlockLibrary.jsx create mode 100644 frontend/src/components/BlockLibrary.tsx delete mode 100644 frontend/src/components/CADCanvas.jsx delete mode 100644 frontend/src/components/CanvasArea.css create mode 100644 frontend/src/components/CanvasArea.tsx delete mode 100644 frontend/src/components/CommandLine.css create mode 100644 frontend/src/components/CommandLine.tsx create mode 100644 frontend/src/components/HistoryPanel.tsx create mode 100644 frontend/src/components/KICopilot.tsx delete mode 100644 frontend/src/components/LayerPanel.jsx create mode 100644 frontend/src/components/LayerPanel.tsx create mode 100644 frontend/src/components/LeftSidebar.tsx create mode 100644 frontend/src/components/MobileDrawers.tsx create mode 100644 frontend/src/components/PluginManager.tsx delete mode 100644 frontend/src/components/PluginRegistry.jsx delete mode 100644 frontend/src/components/PrintPreview/PrintPreview.css create mode 100644 frontend/src/components/PropertiesPanel.tsx delete mode 100644 frontend/src/components/PropertiesPanel/PropertiesPanel.css delete mode 100644 frontend/src/components/RibbonBar.css create mode 100644 frontend/src/components/RibbonBar.tsx create mode 100644 frontend/src/components/RightSidebar.tsx create mode 100644 frontend/src/components/SettingsModal.tsx delete mode 100644 frontend/src/components/SidePanel.css delete mode 100644 frontend/src/components/StatusBar.css create mode 100644 frontend/src/components/StatusBar.tsx delete mode 100644 frontend/src/components/Toolbar.jsx create mode 100644 frontend/src/components/Topbar.tsx create mode 100644 frontend/src/components/TreeView.tsx delete mode 100644 frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/crdt/AwarenessManager.ts create mode 100644 frontend/src/crdt/WebSocketProvider.ts create mode 100644 frontend/src/crdt/YjsDocument.ts create mode 100644 frontend/src/crdt/index.ts create mode 100644 frontend/src/crdt/useYjsBinding.ts create mode 100644 frontend/src/history/HistoryManager.ts create mode 100644 frontend/src/history/index.ts delete mode 100644 frontend/src/index.css create mode 100644 frontend/src/interaction/index.ts delete mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/main.tsx delete mode 100644 frontend/src/pages/Dashboard.jsx create mode 100644 frontend/src/pages/Dashboard.tsx delete mode 100644 frontend/src/pages/Editor.jsx delete mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Login.tsx delete mode 100644 frontend/src/pages/Register.jsx create mode 100644 frontend/src/pages/Register.tsx create mode 100644 frontend/src/plugins/PluginRegistry.ts create mode 100644 frontend/src/plugins/builtin/eventTools.ts create mode 100644 frontend/src/plugins/index.ts create mode 100644 frontend/src/plugins/types.ts delete mode 100644 frontend/src/services/api.js create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/services/backgroundService.ts delete mode 100644 frontend/src/services/blockService.js create mode 100644 frontend/src/services/blockService.ts create mode 100644 frontend/src/services/commandRegistry.ts create mode 100644 frontend/src/services/dimensionService.ts create mode 100644 frontend/src/services/dxfParser.ts create mode 100644 frontend/src/services/dxfWriter.ts create mode 100644 frontend/src/services/exportService.ts create mode 100644 frontend/src/services/importService.ts create mode 100644 frontend/src/services/pdfExport.ts create mode 100644 frontend/src/services/seatingService.ts create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/styles/auth.css delete mode 100644 frontend/src/styles/theme.css delete mode 100644 frontend/src/tools/.gitkeep delete mode 100644 frontend/src/tools/drawing/.gitkeep create mode 100644 frontend/src/tools/modification/GroupTool.ts create mode 100644 frontend/src/tools/modification/geometry.ts create mode 100644 frontend/src/types/cad.types.ts create mode 100644 frontend/src/types/ui.types.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tests/Components.test.tsx create mode 100644 frontend/tests/GroupTool.test.ts create mode 100644 frontend/tests/HistoryManager.test.ts create mode 100644 frontend/tests/IntegrationWorkflow.test.ts create mode 100644 frontend/tests/LayerManager.test.ts create mode 100644 frontend/tests/RenderEngine.test.ts create mode 100644 frontend/tests/SelectionEngine.test.ts create mode 100644 frontend/tests/SnapEngine.test.ts create mode 100644 frontend/tests/SpatialIndex.test.ts create mode 100644 frontend/tests/StressTest.test.ts create mode 100644 frontend/tests/ZoomPanController.test.ts create mode 100644 frontend/tests/commandRegistry.test.ts create mode 100644 frontend/tests/geometry.test.ts create mode 100644 frontend/tests/setup.ts create mode 100644 frontend/tsconfig.json rename frontend/{vite.config.js => vite.config.ts} (56%) create mode 100644 frontend/vitest.config.ts diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..1fd2ce5 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +*.md +*.log diff --git a/frontend/.gitignore b/frontend/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/frontend/.gitignore +++ /dev/null @@ -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? diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e7f0433..c10ed0d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index a36934d..0000000 --- a/frontend/README.md +++ /dev/null @@ -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. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js deleted file mode 100644 index ea36dd3..0000000 --- a/frontend/eslint.config.js +++ /dev/null @@ -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 } }, - }, - }, -]) diff --git a/frontend/index.html b/frontend/index.html index f94d687..dcd313d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,12 @@ - - - - - - - frontend - - -
- - + + + + + + Web CAD + + +
+ + diff --git a/frontend/nginx.conf b/frontend/nginx.conf index f6ee8f4..9ed4134 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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"; + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 454a372..b9603e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,38 +1,95 @@ { - "name": "frontend", - "version": "0.0.0", + "name": "web-cad-frontend", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "frontend", - "version": "0.0.0", + "name": "web-cad-frontend", + "version": "0.1.0", "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" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -41,29 +98,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -80,13 +137,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -96,13 +153,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -112,36 +169,36 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -150,53 +207,62 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -205,32 +271,71 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -238,227 +343,547 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "css-tree": "^3.0.0" }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", - "dev": true, - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=20.19.0" }, "peerDependencies": { - "eslint": "^10.0.0" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "peerDependencies": { + "css-tree": "^3.2.1" }, "peerDependenciesMeta": { - "eslint": { + "css-tree": { "optional": true } } }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=20.19.0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", - "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "dependencies": { - "@humanfs/types": "^0.15.0" + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, "node_modules/@jridgewell/gen-mapping": { @@ -506,69 +931,45 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", - "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", - "optional": true, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" + "pako": "^1.0.6" } }, - "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "dependencies": { + "pako": "^1.0.10" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], "dev": true, "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } + "os": [ + "android" + ] }, - "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", "cpu": [ "arm64" ], @@ -576,15 +977,12 @@ "optional": true, "os": [ "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", "cpu": [ "arm64" ], @@ -592,15 +990,12 @@ "optional": true, "os": [ "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", "cpu": [ "x64" ], @@ -608,15 +1003,25 @@ "optional": true, "os": [ "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", "cpu": [ "x64" ], @@ -624,15 +1029,12 @@ "optional": true, "os": [ "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", "cpu": [ "arm" ], @@ -640,15 +1042,25 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", "cpu": [ "arm64" ], @@ -656,15 +1068,12 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", "cpu": [ "arm64" ], @@ -672,15 +1081,38 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", "cpu": [ "ppc64" ], @@ -688,15 +1120,51 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "cpu": [ "s390x" ], @@ -704,15 +1172,12 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "cpu": [ "x64" ], @@ -720,15 +1185,12 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "cpu": [ "x64" ], @@ -736,15 +1198,25 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", "cpu": [ "arm64" ], @@ -752,33 +1224,12 @@ "optional": true, "os": [ "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", "cpu": [ "arm64" ], @@ -786,15 +1237,25 @@ "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", "cpu": [ "x64" ], @@ -802,247 +1263,405 @@ "optional": true, "os": [ "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true - }, - "node_modules/@tootallnate/once": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", - "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", - "optional": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "peer": true, "dependencies": { - "tslib": "^2.4.0" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "node_modules/@types/rbush": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", + "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", "dev": true }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "dependencies": { + "@types/prop-types": "*", "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^18.0.0" } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "dev": true, "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" }, "peerDependenciesMeta": { - "@rolldown/plugin-babel": { + "msw": { "optional": true }, - "babel-plugin-react-compiler": { + "vite": { "optional": true } } }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "optional": true - }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "optional": true - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "devOptional": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-globals": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", - "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", - "optional": true, - "dependencies": { - "acorn": "^7.1.1", - "acorn-walk": "^7.1.1" - } - }, - "node_modules/acorn-globals/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "optional": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "optional": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", "dev": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "tinyrainbow": "^1.2.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-leveldown": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz", + "integrity": "sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "immediate": "^3.2.3", + "level-concat-iterator": "~2.0.0", + "level-supports": "~1.0.0", + "xtend": "~4.0.0" + }, + "engines": { + "node": ">=6" } }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "optional": true, + "dev": true, + "peer": true, "engines": { "node": ">=8" } }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "optional": true - }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "deprecated": "This package is no longer supported.", - "optional": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "peer": true, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" + "dequal": "^2.0.3" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "engines": { - "node": "18 || 20 || >=22" + "node": ">=12" } }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true + }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "dev": true, "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -1051,28 +1670,19 @@ "node": ">=6.0.0" } }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "require-from-string": "^2.0.2" } }, - "node_modules/browser-process-hrtime": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", - "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", - "optional": true - }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -1089,10 +1699,10 @@ } ], "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -1102,22 +1712,43 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -1134,117 +1765,55 @@ } ] }, - "node_modules/canvas": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", - "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", - "hasInstallScript": true, - "optional": true, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.0", - "nan": "^2.17.0", - "simple-get": "^3.0.3" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "optional": true, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, "engines": { - "node": ">=10" + "node": ">= 16" } }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "optional": true, - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "optional": true - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "optional": true - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">= 8" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "optional": true - }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "optional": true, - "dependencies": { - "cssom": "~0.3.6" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "optional": true + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true }, "node_modules/csstype": { "version": "3.2.3", @@ -1253,36 +1822,23 @@ "dev": true }, "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "optional": true, + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "optional": true, - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "dependencies": { "ms": "^2.1.3" }, @@ -1299,126 +1855,143 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "optional": true - }, - "node_modules/decompress-response": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", - "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", - "optional": true, - "dependencies": { - "mimic-response": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, "engines": { - "node": ">=0.4.0" + "node": ">=6" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "optional": true - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", + "node_modules/deferred-leveldown": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/deferred-leveldown/-/deferred-leveldown-5.3.0.tgz", + "integrity": "sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", "optional": true, "dependencies": { - "webidl-conversions": "^7.0.0" + "abstract-leveldown": "~6.2.1", + "inherits": "^2.0.3" }, "engines": { - "node": ">=12" + "node": ">=6" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "engines": { - "node": ">= 0.4" + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "peer": true + }, + "node_modules/dxf-parser": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/dxf-parser/-/dxf-parser-1.1.2.tgz", + "integrity": "sha512-GPTumUvRkounlIazLIyJMmTWt+nlg+ksS0Hdm8jWvejmZKBTz6gvHTam76wRm4PQMma5sgKLThblQyeIJcH79Q==", + "dependencies": { + "loglevel": "^1.7.1" } }, "node_modules/electron-to-chromium": { - "version": "1.5.361", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", - "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "version": "1.5.378", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", + "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", "dev": true }, - "node_modules/emoji-regex": { + "node_modules/encoding-down": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/encoding-down/-/encoding-down-6.3.0.tgz", + "integrity": "sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "optional": true, + "dependencies": { + "abstract-leveldown": "^6.2.1", + "inherits": "^2.0.3", + "level-codec": "^9.0.0", + "level-errors": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "optional": true - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, "engines": { - "node": ">= 0.4" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "optional": true, "dependencies": { - "es-errors": "^1.3.0" + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "node": ">=12" }, - "engines": { - "node": ">= 0.4" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { @@ -1430,388 +2003,24 @@ "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "optional": true, - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/eslint": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", - "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "@types/estree": "^1.0.0" } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", - "dev": true, - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", - "dev": true, - "peerDependencies": { - "eslint": "^9 || ^10" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "optional": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "devOptional": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "devOptional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fabric": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/fabric/-/fabric-5.5.2.tgz", - "integrity": "sha512-krvnxxyhslNBAkG0SpxPxFVsD6YD2mTJByyPxV92E1BxXtjXEcWsTSJpzX8+2Jprvs7oOLYFAjHO3o1ZvjoBrQ==", - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "canvas": "^2.8.0", - "jsdom": "^19.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "engines": { "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "optional": true - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1826,35 +2035,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "deprecated": "This package is no longer supported.", - "optional": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1864,259 +2044,51 @@ "node": ">=6.9.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "optional": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "optional": true - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "optional": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "optional": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "optional": true - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "dependencies": { - "hermes-estree": "0.25.1" - } - }, "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "optional": true, + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, "dependencies": { - "whatwg-encoding": "^2.0.0" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "optional": true, - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } + "node_modules/immediate": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.3.0.tgz", + "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==", + "optional": true }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, "engines": { - "node": ">= 4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "optional": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "node": ">=8" } }, "node_modules/inherits": { @@ -2125,93 +2097,59 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "optional": true }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "optional": true - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/jsdom": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-19.0.0.tgz", - "integrity": "sha512-RYAyjCbxy/vri/CfnjUWJQQtZ3LKlLnDqj+9XLNnJPgEGeirZs3hllKR20re8LUZ6o1b1X4Jat+Qd26zmP41+A==", - "optional": true, + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, "dependencies": { - "abab": "^2.0.5", - "acorn": "^8.5.0", - "acorn-globals": "^6.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.1", - "decimal.js": "^10.3.1", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.0", - "parse5": "6.0.1", - "saxes": "^5.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.0.0", - "w3c-hr-time": "^1.0.2", - "w3c-xmlserializer": "^3.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^10.0.0", - "ws": "^8.2.3", - "xml-name-validator": "^4.0.0" + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -2219,6 +2157,15 @@ } } }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2231,24 +2178,6 @@ "node": ">=6" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2261,292 +2190,200 @@ "node": ">=6" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "node_modules/level": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/level/-/level-6.0.1.tgz", + "integrity": "sha512-psRSqJZCsC/irNhfHzrVZbmPYXDcEYhA5TVNwr+V92jF44rbf86hqGp8fiT702FyiArScYIlPSBTDUASCVNSpw==", + "optional": true, "dependencies": { - "json-buffer": "3.0.1" + "level-js": "^5.0.0", + "level-packager": "^5.1.0", + "leveldown": "^5.4.0" + }, + "engines": { + "node": ">=8.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/level" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, + "node_modules/level-codec": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/level-codec/-/level-codec-9.0.2.tgz", + "integrity": "sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==", + "deprecated": "Superseded by level-transcoder (https://github.com/Level/community#faq)", + "optional": true, "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "buffer": "^5.6.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=6" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, + "node_modules/level-concat-iterator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/level-concat-iterator/-/level-concat-iterator-2.0.1.tgz", + "integrity": "sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/level-errors/-/level-errors-2.0.1.tgz", + "integrity": "sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "optional": true, "dependencies": { - "detect-libc": "^2.0.3" + "errno": "~0.1.1" }, "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "node": ">=6" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/level-iterator-stream": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/level-iterator-stream/-/level-iterator-stream-4.0.2.tgz", + "integrity": "sha512-ZSthfEqzGSOMWoUGhTXdX9jv26d32XJuHz/5YnuHZzH6wldfWMOVwI9TBtKcya4BKTyTt3XVA0A3cF3q5CY30Q==", "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "dependencies": { - "p-locate": "^5.0.0" + "inherits": "^2.0.4", + "readable-stream": "^3.4.0", + "xtend": "^4.0.2" }, "engines": { - "node": ">=10" + "node": ">=6" + } + }, + "node_modules/level-js": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/level-js/-/level-js-5.0.2.tgz", + "integrity": "sha512-SnBIDo2pdO5VXh02ZmtAyPP6/+6YTJg2ibLtl9C34pWvmtMEmRTWpra+qO/hifkUtBTOtfx6S9vLDjBsBK4gRg==", + "deprecated": "Superseded by browser-level (https://github.com/Level/community#faq)", + "optional": true, + "dependencies": { + "abstract-leveldown": "~6.2.3", + "buffer": "^5.5.0", + "inherits": "^2.0.3", + "ltgt": "^2.1.2" + } + }, + "node_modules/level-packager": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/level-packager/-/level-packager-5.1.1.tgz", + "integrity": "sha512-HMwMaQPlTC1IlcwT3+swhqf/NUO+ZhXVz6TY1zZIIZlIR0YSn8GtAAWmIvKjNY16ZkEg/JcpAuQskxsXqC0yOQ==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "optional": true, + "dependencies": { + "encoding-down": "^6.3.0", + "levelup": "^4.3.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-supports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-1.0.1.tgz", + "integrity": "sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==", + "optional": true, + "dependencies": { + "xtend": "^4.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leveldown": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/leveldown/-/leveldown-5.6.0.tgz", + "integrity": "sha512-iB8O/7Db9lPaITU1aA2txU/cBEXAt4vWwKQRrrWuS6XDgbP4QZGj9BL2aNbwb002atoQ/lIotJkfyzz+ygQnUQ==", + "deprecated": "Superseded by classic-level (https://github.com/Level/community#faq)", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "abstract-leveldown": "~6.2.1", + "napi-macros": "~2.0.0", + "node-gyp-build": "~4.1.0" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/levelup": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/levelup/-/levelup-4.4.0.tgz", + "integrity": "sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "optional": true, + "dependencies": { + "deferred-leveldown": "~5.3.0", + "level-errors": "~2.0.0", + "level-iterator-stream": "~4.0.0", + "level-supports": "~1.0.0", + "xtend": "~4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2556,142 +2393,56 @@ "yallist": "^3.0.2" } }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "optional": true, - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-response": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", - "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", - "optional": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "optional": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "node_modules/ltgt": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", + "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", "optional": true }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "optional": true, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "peer": true, "bin": { - "mkdirp": "bin/cmd.js" - }, + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, "engines": { - "node": ">=10" + "node": ">=4" } }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/nan": { - "version": "2.27.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", - "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", - "optional": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -2706,193 +2457,73 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "optional": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "node_modules/napi-macros": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.0.0.tgz", + "integrity": "sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg==", "optional": true }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "optional": true - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "node_modules/node-gyp-build": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.1.1.tgz", + "integrity": "sha512-dSq1xmcPDKPZ2EED2S6zw/b9NKsqzXRE6dVr8TVQnI3FJOTteUMuqF3Qqs6LZg+mLGYJWqQzMbIjMtJqTv87nQ==", "optional": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" } }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, "engines": { "node": ">=18" } }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "optional": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "deprecated": "This package is no longer supported.", - "optional": true, - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, - "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", - "optional": true - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "optional": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, "node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", - "optional": true - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, - "engines": { - "node": ">=8" + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "optional": true, - "engines": { - "node": ">=0.10.0" - } + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "engines": { - "node": ">=8" + "node": ">= 14.16" + } + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" } }, "node_modules/picocolors": { @@ -2901,18 +2532,6 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2941,103 +2560,86 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "optional": true, + "peer": true, "dependencies": { - "punycode": "^2.3.1" + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "optional": true + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "devOptional": true, + "dev": true, "engines": { "node": ">=6" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "optional": true + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==" + }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "dependencies": { + "quickselect": "^3.0.0" + } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dependencies": { - "scheduler": "^0.27.0" + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^18.3.1" } }, - "node_modules/react-router": { - "version": "7.15.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", - "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "peer": true }, - "node_modules/react-router-dom": { - "version": "7.15.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", - "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", - "dependencies": { - "react-router": "7.15.1" - }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "node": ">=0.10.0" } }, "node_modules/readable-stream": { @@ -3054,59 +2656,70 @@ "node": ">= 6" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "optional": true - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "optional": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, "dependencies": { - "@oxc-project/types": "=0.132.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" } }, "node_modules/safe-buffer": { @@ -3129,115 +2742,40 @@ ], "optional": true }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "optional": true - }, "node_modules/saxes": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", - "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", - "optional": true, + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, "dependencies": { "xmlchars": "^2.2.0" }, "engines": { - "node": ">=10" + "node": ">=v12.22.7" } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "devOptional": true, + "dev": true, "bin": { "semver": "bin/semver.js" } }, - "node_modules/set-blocking": { + "node_modules/siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "optional": true - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "optional": true - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "optional": true - }, - "node_modules/simple-get": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", - "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", - "optional": true, - "dependencies": { - "decompress-response": "^4.2.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, - "engines": { - "node": ">=0.10.0" - } + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true }, "node_modules/source-map-js": { "version": "1.2.1", @@ -3248,6 +2786,18 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -3257,27 +2807,13 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "optional": true, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" + "min-indent": "^1.0.0" }, "engines": { "node": ">=8" @@ -3287,101 +2823,114 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "optional": true + "dev": true }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "optional": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, "engines": { - "node": ">=10" + "node": "^18.0.0 || >=20.0.0" } }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.4.tgz", + "integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==", "dev": true, "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "tldts-core": "^7.4.4" }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "bin": { + "tldts": "bin/cli.js" } }, + "node_modules/tldts-core": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.4.tgz", + "integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==", + "dev": true + }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "optional": true, + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^7.0.5" }, "engines": { - "node": ">=6" + "node": ">=16" } }, "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "optional": true, + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "node": ">=20" } }, "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "optional": true + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">= 0.8.0" + "node": ">=14.17" } }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "optional": true, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, "engines": { - "node": ">= 4.0.0" + "node": ">=20.18.1" } }, "node_modules/update-browserslist-db": { @@ -3414,25 +2963,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "optional": true, - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3440,22 +2970,20 @@ "optional": true }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.0.0 || >=20.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -3464,35 +2992,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, "less": { "optional": true }, + "lightningcss": { + "optional": true + }, "sass": { "optional": true }, @@ -3507,155 +3025,253 @@ }, "terser": { "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true } } }, - "node_modules/w3c-hr-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", - "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", - "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", - "optional": true, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, "dependencies": { - "browser-process-hrtime": "^1.0.0" + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, "node_modules/w3c-xmlserializer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz", - "integrity": "sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg==", - "optional": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, "dependencies": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "optional": true, + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "optional": true, - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" + "node": ">=20" } }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "optional": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, "engines": { - "node": ">=12" + "node": ">=20" } }, "node_modules/whatwg-url": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-10.0.0.tgz", - "integrity": "sha512-CLxxCmdUby142H5FZzn4D8ikO1cmypvXVQktsgosNy4a4BHrDHeciBBGZhb0bNoR5/MltoCatso+vFjjGx8t0w==", - "optional": true, - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, "dependencies": { - "isexe": "^2.0.0" + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" }, "bin": { - "node-which": "bin/node-which" + "why-is-node-running": "cli.js" }, "engines": { - "node": ">= 8" + "node": ">=8" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "optional": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "optional": true - }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz", + "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", "optional": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "dependencies": { + "async-limiter": "~1.0.0" } }, "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "optional": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "optional": true + "dev": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "optional": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y-leveldb": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz", + "integrity": "sha512-6ulEn5AXfXJYi89rXPEg2mMHAyyw8+ZfeMMdOtBbV8FJpQ1NOrcgi6DTAcXof0dap84NjHPT2+9d0rb6cFsjEg==", + "optional": true, + "dependencies": { + "level": "^6.0.1", + "lib0": "^0.2.31" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-websocket": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-2.1.0.tgz", + "integrity": "sha512-WHYDRqomaGkkaujtowCDwL8KYk+t1zQCGIgKyvxvchhjTQlMgWXRHJK+FDEcWmHA7I7o/4fy0eniOrtmz0e4mA==", + "dependencies": { + "lib0": "^0.2.52", + "lodash.debounce": "^4.0.8", + "y-protocols": "^1.0.5" + }, + "bin": { + "y-websocket": "bin/server.cjs", + "y-websocket-server": "bin/server.cjs" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "optionalDependencies": { + "ws": "^6.2.1", + "y-leveldb": "^0.1.0" + }, + "peerDependencies": { + "yjs": "^13.5.6" + } }, "node_modules/yallist": { "version": "3.1.1", @@ -3663,37 +3279,20 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, + "node_modules/yjs": { + "version": "13.6.31", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", + "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", + "dependencies": { + "lib0": "^0.2.99" + }, "engines": { - "node": ">=10" + "node": ">=16.0.0", + "npm": ">=8.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" } } } diff --git a/frontend/package.json b/frontend/package.json index 84b01ef..48752eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg deleted file mode 100644 index 6893eb1..0000000 --- a/frontend/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/frontend/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -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); - } -} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx deleted file mode 100644 index e309158..0000000 --- a/frontend/src/App.jsx +++ /dev/null @@ -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
Loading...
; - if (!user) return ; - return children; -}; - -const AppContent = () => ( - - - } /> - } /> - } /> - } /> - } /> - } /> - - -); - -const App = () => ( - - - -); - -export default App; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f0ea0ab --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,1089 @@ +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import type { + Theme, RibbonTab, ViewMode, RightPanel, DrawerTab, + CursorPos, CommandHistoryEntry, KIMessage, KISuggestion, +} from './types/ui.types'; +import type { CADElement, CADLayer, BlockDefinition } from './types/cad.types'; +import { createDefaultBlocks, BlockService } from './services/blockService'; +import { SeatingService } from './services/seatingService'; +import type { ToolState } from './interaction'; +import { GroupManager, type ElementGroup } from './tools/modification/GroupTool'; +import Topbar from './components/Topbar'; +import RibbonBar from './components/RibbonBar'; +import LeftSidebar from './components/LeftSidebar'; +import CanvasArea from './components/CanvasArea'; +import RightSidebar from './components/RightSidebar'; +import CommandLine from './components/CommandLine'; +import StatusBar from './components/StatusBar'; +import MobileDrawers from './components/MobileDrawers'; +import BackgroundImport from './components/BackgroundImport'; +import HistoryPanel from './components/HistoryPanel'; +import { BackgroundService, type BackgroundConfig } from './services/backgroundService'; +import { HistoryManager, type CADStateSnapshot, type HistoryEntry } from './history'; +import { getCommandRegistry } from './services/commandRegistry'; +import { importFile, type ImportResult } from './services/importService'; +import { exportProject, downloadBlob, type ExportFormat } from './services/exportService'; +import type { ProjectData } from './types/cad.types'; +import { loadProjectDataTyped, createElementTyped, updateElement, deleteElement as apiDeleteElement, createLayerTyped, updateLayer, deleteLayer as apiDeleteLayer, createBlockTyped, updateBlock, deleteBlock as apiDeleteBlock, aiChat } from './services/api'; +import { useYjsBinding } from './crdt'; +import { registerBuiltinPlugins, pluginRegistry } from './plugins'; +import type { PluginContext } from './plugins'; +import './styles.css'; +import './styles/auth.css'; +import { useAuth } from './contexts/AuthContext'; +import { Login } from './pages/Login'; +import { Register } from './pages/Register'; +import { Dashboard } from './pages/Dashboard'; + +// ─── Mock Data ────────────────────────────────────────────── +const initialLayers: CADLayer[] = [ + { id: 'layer-0', name: 'Wände', visible: true, locked: false, color: '#e74c3c', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null }, + { id: 'layer-1', name: 'Türen', visible: true, locked: false, color: '#3498db', lineType: 'solid', transparency: 0, sortOrder: 1, parentId: null }, + { id: 'layer-2', name: 'Bestuhlung', visible: true, locked: false, color: '#2ecc71', lineType: 'solid', transparency: 0, sortOrder: 2, parentId: null }, + { id: 'layer-3', name: 'Bühne', visible: true, locked: false, color: '#f39c12', lineType: 'solid', transparency: 0, sortOrder: 3, parentId: null }, + { id: 'layer-4', name: 'Hintergrund', visible: true, locked: true, color: '#95a5a6', lineType: 'dotted', transparency: 50, sortOrder: 4, parentId: null }, +]; + +const initialBlocks: BlockDefinition[] = createDefaultBlocks(); + +const initialCommandHistory: CommandHistoryEntry[] = [ + { prefix: '·', text: 'Bereit · Werkzeug: Auswahl · 110 Objekte · 5 Ebenen', type: 'info' }, + { prefix: '·', text: 'Auto-Save aktiv · letzte Speicherung vor 3 Sekunden', type: 'info' }, +]; + +const initialKIMessages: KIMessage[] = [ + { id: 'ki-1', role: 'assistant', content: 'Hallo! Ich bin der KI Copilot. Wie kann ich helfen?' }, +]; + +const initialKISuggestions: KISuggestion[] = [ + { id: 'sug-1', label: 'Bestuhlung automatisch generieren' }, + { id: 'sug-2', label: 'Maße analysieren' }, + { id: 'sug-3', label: 'Flächen berechnen' }, +]; + +// Prevent duplicate drawing creation across StrictMode double-render +const loadedProjects = new Set(); + +// ─── CAD Editor Component ─────────────────────────────── +interface CADEditorProps { + projectId: string; + token: string; + onNavigateBack: () => void; +} + +const CADEditor: React.FC = ({ projectId, token, onNavigateBack }) => { + // Theme + const [theme, setTheme] = useState('dark'); + + // Auth user for collaboration + const { user } = useAuth(); + + // Yjs collaboration binding + const collab = useYjsBinding({ + docName: `project-${projectId}`, + userId: user?.id || 'anonymous', + userName: user?.name || 'Gast', + userColor: '#3498db', + enabled: true, + }); + + // Project + const [projectName, setProjectName] = useState('Unbenannt'); + const [savedStatus, setSavedStatus] = useState('Lädt…'); + const [drawingId, setDrawingId] = useState(null); + + // Ribbon + const [activeRibbonTab, setActiveRibbonTab] = useState('start'); + + // Tools + const [activeTool, setActiveTool] = useState('select'); + + // Canvas / View + const [viewMode, setViewMode] = useState('2d'); + const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); + const [gridEnabled, setGridEnabled] = useState(true); + const [orthoEnabled, setOrthoEnabled] = useState(false); + const [snapEnabled, setSnapEnabled] = useState(true); + const [polarEnabled, setPolarEnabled] = useState(false); + + // Right sidebar + const [activeRightPanel, setActiveRightPanel] = useState('tool'); + const [selectedElement, setSelectedElement] = useState(null); + const [layers, setLayers] = useState(initialLayers); + const [activeLayerId, setActiveLayerId] = useState('layer-0'); + const [blocks, setBlocks] = useState(initialBlocks); + const [blockCategory, setBlockCategory] = useState('Alle'); + const [selectedTemplate, setSelectedTemplate] = useState(null); + + // Command line + const [commandHistory, setCommandHistory] = useState(initialCommandHistory); + + // KI Copilot + const [kiMessages, setKIMessages] = useState(initialKIMessages); + const [kiSuggestions] = useState(initialKISuggestions); + const [kiLoading, setKiLoading] = useState(false); + + // Plugin system + useEffect(() => { + const ctx: PluginContext = { + addElement: (el) => setElements((prev) => [...prev, el]), + removeElement: (id) => setElements((prev) => prev.filter((e) => e.id !== id)), + updateElement: (id, props) => setElements((prev) => prev.map((e) => (e.id === id ? { ...e, ...props } : e))), + getElements: () => elements, + getLayers: () => layers, + getActiveLayerId: () => activeLayerId, + showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]), + log: (msg) => console.log(`[Plugin] ${msg}`), + }; + pluginRegistry.setContext(ctx); + registerBuiltinPlugins(); + pluginRegistry.initDefaults(); + }, []); + + // Status bar + const onlineCount = collab.cursors.length + 1; + + // Mobile drawers + const [mobileLeftOpen, setMobileLeftOpen] = useState(false); + const [mobileRightOpen, setMobileRightOpen] = useState(false); + const [activeDrawerTab, setActiveDrawerTab] = useState('tool'); + + // Background + const [bgImportOpen, setBgImportOpen] = useState(false); + const [bgConfig, setBgConfig] = useState(null); + const bgServiceRef = React.useRef(new BackgroundService()); + + // History panel + const [historyPanelOpen, setHistoryPanelOpen] = useState(false); + + // Elements + undo/redo (HistoryManager) + const [elements, setElements] = useState([]); + const historyManagerRef = React.useRef(new HistoryManager()); + const [historyEntries, setHistoryEntries] = useState([]); + const [toolState, setToolState] = useState(null); + + const seatCount = useMemo(() => { + const svc = new SeatingService(); + return svc.countSeats(elements).total; + }, [elements]); + + // Groups + const groupManagerRef = React.useRef(new GroupManager()); + const [groups, setGroups] = useState([]); + + // Clipboard (for copy/paste) + const clipboardRef = React.useRef(null); + + // ─── Handlers ─────────────────────────────────────────── + const handleThemeToggle = useCallback(() => { + setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); + }, []); + + const syncHistory = useCallback(() => { + setHistoryEntries(historyManagerRef.current.getHistory()); + }, []); + + const restoreSnapshot = useCallback((snap: CADStateSnapshot) => { + setElements(snap.elements); + setLayers(snap.layers); + setBlocks(snap.blocks); + setGroups(snap.groups); + setBgConfig(snap.bgConfig); + }, []); + + const handleUndo = useCallback(() => { + const snap = historyManagerRef.current.undo(); + if (snap) { + restoreSnapshot(snap); + syncHistory(); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rückgängig: letzte Aktion', type: 'info' }]); + } + }, [restoreSnapshot, syncHistory]); + + const handleRedo = useCallback(() => { + const snap = historyManagerRef.current.redo(); + if (snap) { + restoreSnapshot(snap); + syncHistory(); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Wiederherstellen: letzte Aktion', type: 'info' }]); + } + }, [restoreSnapshot, syncHistory]); + + const pushHistorySnapshot = useCallback((label: string) => { + historyManagerRef.current.pushSnapshot({ + elements, + layers, + blocks, + groups, + bgConfig, + }, label); + syncHistory(); + }, [elements, layers, blocks, groups, bgConfig, syncHistory]); + + // Initialize HistoryManager with initial state on mount + const historyInitRef = React.useRef(false); + React.useEffect(() => { + if (historyInitRef.current) return; + historyInitRef.current = true; + historyManagerRef.current.initialize({ + elements: [], + layers: initialLayers, + blocks: initialBlocks, + groups: [], + bgConfig: null, + }); + syncHistory(); + }, [syncHistory]); + + // Load project data from backend on mount + const [dataLoaded, setDataLoaded] = useState(false); + React.useEffect(() => { + if (!projectId || !token) return; + let cancelled = false; + (async () => { + try { + setSavedStatus('Lädt…'); + const data = await loadProjectDataTyped(token, projectId); + if (cancelled) return; + setProjectName(data.project.name); + setDrawingId(data.drawing?.id || null); + if (data.elements.length > 0) setElements(data.elements); + if (data.layers.length > 0) setLayers(data.layers); + if (data.blocks.length > 0) setBlocks(data.blocks); + // Save initial layers to backend if backend has none + if (data.layers.length === 0 && data.drawing) { + for (const layer of initialLayers) { + createLayerTyped(token, data.drawing.id, layer).catch(() => {}); + } + } + setSavedStatus('gespeichert'); + setDataLoaded(true); + // Push initial data to Yjs CRDT after load + if (collab.status === 'connected') { + collab.loadFromState({ elements: data.elements, layers: data.layers, blocks: data.blocks }); + } + } catch (err) { + console.error('Failed to load project:', err); + setSavedStatus('Fehler beim Laden'); + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId, token]); + + // Sync remote → local: when Yjs data changes from other users, update local state + React.useEffect(() => { + if (collab.status !== 'connected') return; + // Skip if collab data is empty (not yet loaded) + if (collab.elements.length === 0 && collab.layers.length === 0 && collab.blocks.length === 0) return; + + // Only update local state if remote data differs AND local is not ahead of remote + // (prevents race condition where local element is overwritten by stale Yjs sync) + const localIds = new Set(elements.map(e => e.id)); + const remoteIds = new Set(collab.elements.map(e => e.id)); + const localHasExtra = elements.some(e => !remoteIds.has(e.id)); + const remoteHasExtra = collab.elements.some(e => !localIds.has(e.id)); + // Only sync from remote if remote has elements local doesn't have AND local doesn't have unsaved elements + if (remoteHasExtra && !localHasExtra) { + setElements(collab.elements); + } else if (remoteHasExtra && localHasExtra) { + // Merge: keep local elements, update any that changed remotely + const merged = elements.map(el => { + const remote = collab.elements.find(e => e.id === el.id); + return remote || el; + }); + // Add remote-only elements + collab.elements.forEach(el => { if (!localIds.has(el.id)) merged.push(el); }); + setElements(merged); + } + + const sameLayers = collab.layers.length === layers.length && + collab.layers.every((l, i) => l.id === layers[i]?.id); + if (!sameLayers) setLayers(collab.layers); + + const sameBlocks = collab.blocks.length === blocks.length && + collab.blocks.every((b, i) => b.id === blocks[i]?.id); + if (!sameBlocks) setBlocks(collab.blocks); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collab.elements, collab.layers, collab.blocks, collab.status]); + + const handleElementCreated = useCallback((el: CADElement) => { + const elWithLayer = el.layerId ? el : { ...el, layerId: activeLayerId }; + setElements((prev) => { + const newElements = [...prev, elWithLayer]; + historyManagerRef.current.pushSnapshot({ + elements: newElements, layers, blocks, groups, bgConfig, + }, 'Element erstellt'); + return newElements; + }); + syncHistory(); + // Push to Yjs CRDT for real-time sync + collab.setElement(elWithLayer); + // Save to backend + if (drawingId && token) { + setSavedStatus('Speichert…'); + createElementTyped(token, drawingId, elWithLayer).then(() => { + setSavedStatus('gespeichert'); + }).catch((err) => { + console.error('Failed to save element:', err); + setSavedStatus('Fehler beim Speichern'); + }); + } + }, [layers, blocks, groups, bgConfig, syncHistory, drawingId, token, collab, activeLayerId]); + + const handleToggleElementVisible = useCallback((id: string) => { + setElements((prev) => prev.map((e) => { + if (e.id === id) { + const newEl = { ...e, properties: { ...e.properties, visible: e.properties?.visible === false ? true : false } }; + if (token) { + setSavedStatus("Speichert…"); + updateElement(token, newEl.id, newEl).then(() => setSavedStatus("gespeichert")).catch(() => setSavedStatus("Fehler")); + } + return newEl; + } + return e; + })); + }, [token]); + + const handleElementsDeleted = useCallback((ids: string[]) => { + setElements((prev) => { + const newElements = prev.filter((e) => !ids.includes(e.id)); + historyManagerRef.current.pushSnapshot({ + elements: newElements, layers, blocks, groups, bgConfig, + }, `${ids.length} Element(e) gelöscht`); + return newElements; + }); + syncHistory(); + // Push deletions to Yjs CRDT for real-time sync + ids.forEach(id => collab.deleteElement(id)); + // Delete from backend + if (token) { + setSavedStatus('Speichert…'); + Promise.all(ids.map(id => apiDeleteElement(token, id))).then(() => { + setSavedStatus('gespeichert'); + }).catch((err) => { + console.error('Failed to delete elements:', err); + setSavedStatus('Fehler beim Speichern'); + }); + } + }, [elements, layers, blocks, groups, bgConfig, syncHistory, token, collab]); + + const handleElementsModified = useCallback((modified: CADElement[]) => { + setElements((prev) => { + const newElements = prev.map((e) => { + const found = modified.find((m) => m.id === e.id); + return found ?? e; + }); + historyManagerRef.current.pushSnapshot({ + elements: newElements, layers, blocks, groups, bgConfig, + }, `${modified.length} Element(e) geändert`); + return newElements; + }); + syncHistory(); + // Push modifications to Yjs CRDT for real-time sync + modified.forEach(el => collab.setElement(el)); + // Update in backend + if (token) { + setSavedStatus('Speichert…'); + Promise.all(modified.map(el => updateElement(token, el.id, el))).then(() => { + setSavedStatus('gespeichert'); + }).catch((err) => { + console.error('Failed to update elements:', err); + setSavedStatus('Fehler beim Speichern'); + }); + } + }, [layers, blocks, groups, bgConfig, syncHistory, token, collab]); + + const lastCursorUpdateRef = useRef(0); + const handleCursorMoved = useCallback((x: number, y: number) => { + const now = performance.now(); + if (now - lastCursorUpdateRef.current < 33) return; // throttle to ~30fps + lastCursorUpdateRef.current = now; + setCursorPos({ x, y }); + collab.setCursor(x, y); + }, [collab]); + + const handleToolStateChanged = useCallback((state: ToolState) => { + setToolState(state); + }, []); + + const handleTextEdit = useCallback((el: CADElement) => { + const text = window.prompt('Text eingeben:', ''); + if (text !== null && text.trim() !== '') { + setElements((prev) => prev.map((e) => + e.id === el.id ? { ...e, properties: { ...e.properties, text } } : e + )); + } + }, []); + + const handleCommandTrigger = useCallback((msg: string) => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]); + }, []); + + // Block management handlers + const handleRenameBlock = useCallback((id: string, name: string) => { + setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, name } : b)); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block umbenannt: ${name}`, type: 'info' }]); + }, []); + + const handleDuplicateBlock = useCallback((id: string) => { + setBlocks((prev) => { + const block = prev.find((b) => b.id === id); + if (!block) return prev; + const newId = `blk-${Date.now()}`; + const copy: BlockDefinition = { + ...block, + id: newId, + name: `${block.name} (Kopie)`, + elements: block.elements.map((el) => ({ ...el, id: `${el.id}_copy_${Date.now()}` })), + }; + return [...prev, copy]; + }); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block dupliziert', type: 'info' }]); + }, []); + + const handleDeleteBlock = useCallback((id: string) => { + setBlocks((prev) => prev.filter((b) => b.id !== id)); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block gelöscht', type: 'info' }]); + // Delete from backend + if (token) { + apiDeleteBlock(token, id).catch((err) => { + console.error('Failed to delete block:', err); + }); + } + }, [token]); + + const handleSvgImport = useCallback((svg: string, name: string, category: string) => { + const svc = new BlockService(); + const block = svc.importSVG(svg, name, category); + setBlocks((prev) => [...prev, block]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `SVG importiert: ${name}`, type: 'info' }]); + }, []); + + const handleSaveGroupAsBlock = useCallback((name: string) => { + const selectedEls = selectedElement ? [selectedElement] : []; + setBlocks((prev) => { + const newId = `blk_grp_${Date.now()}`; + const block: BlockDefinition = { + id: newId, + name, + description: 'Aus Auswahl erstellt', + category: 'Custom', + elements: selectedEls.map(el => ({ ...el, id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` })), + }; + return [...prev, block]; + }); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block erstellt: ${name} (${selectedEls.length} Elemente)`, type: 'info' }]); + }, [selectedElement]); + + const handleBlockCategoryChange = useCallback((cat: string) => { + setBlockCategory(cat); + }, []); + + const handleTemplateSelect = useCallback((templateName: string | null) => { + setSelectedTemplate(templateName); + if (templateName) { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Vorlage gewählt: ${templateName} – Klicken zum Platzieren`, type: 'info' }]); + } + }, []); + + const handleBlockSearch = useCallback((_query: string) => { + // Search is handled locally in BlockLibrary component + }, []); + + const handleDragBlock = useCallback((blockId: string) => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block gezogen: ${blockId}`, type: 'info' }]); + }, []); + + const handleBlockDrop = useCallback((blockId: string, x: number, y: number) => { + const svc = new BlockService(); + blocks.forEach(b => svc.addBlock(b)); + const instance = svc.createInstance(blockId, x, y, activeLayerId); + if (instance) { + setElements((prev) => [...prev, instance]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block platziert: ${blockId} bei (${x.toFixed(2)}, ${y.toFixed(2)})`, type: 'info' }]); + } + }, [blocks, activeLayerId]); + + const handleSelectionChange = useCallback((selectedIds: string[]) => { + if (selectedIds.length === 1) { + const el = elements.find(e => e.id === selectedIds[0]); + setSelectedElement(el ?? null); + } else { + setSelectedElement(null); + } + }, [elements]); + + const handleImport = useCallback(async (file: File) => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiere ${file.name}...`, type: 'info' }]); + const result = await importFile(file); + if (result.success) { + setElements((prev) => [...prev, ...result.elements]); + if (result.layers && result.layers.length > 0) { + setLayers((prev) => { + const existingIds = new Set(prev.map(l => l.id)); + const newLayers = result.layers!.filter(l => !existingIds.has(l.id)); + return [...prev, ...newLayers]; + }); + } + if (result.blocks && result.blocks.length > 0) { + setBlocks((prev) => [...prev, ...result.blocks!]); + } + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiert: ${result.elements.length} Elemente aus ${file.name}`, type: 'info' }]); + if (result.warnings.length > 0) { + result.warnings.forEach(w => setCommandHistory((prev) => [...prev, { prefix: '·', text: w, type: 'info' }])); + } + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Import fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]); + } + }, []); + + const handleExport = useCallback(async (format: ExportFormat) => { + const projectData: ProjectData = { + version: '1.0', + name: projectName, + layers, + elements, + blocks, + }; + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiere als ${format.toUpperCase()}...`, type: 'info' }]); + const result = await exportProject(projectData, { format }); + if (result.success && result.blob) { + downloadBlob(result.blob, result.filename); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiert: ${result.filename}`, type: 'info' }]); + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Export fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]); + } + }, [layers, elements, blocks]); + + const handleRibbonAction = useCallback((action: string) => { + setCommandHistory((prev) => [...prev, { prefix: '›', text: action, type: 'command' }]); + + // ─── File actions ─── + if (action === 'new') { + onNavigateBack(); + return; + } + if (action === 'open') { + onNavigateBack(); + return; + } + if (action === 'save') { + setSavedStatus('Gespeichert'); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Projekt gespeichert', type: 'info' }]); + // Save elements to backend if drawingId exists + if (drawingId && token) { + elements.forEach((el) => { + if (!el.id.startsWith('el-')) return; + updateElement(token, el.id, el).catch(() => {}); + }); + } + return; + } + if (action === 'import') { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.dxf,.svg,.json'; + input.onchange = () => { + if (input.files && input.files[0]) { + handleImport(input.files[0]); + } + }; + input.click(); + return; + } + if (action === 'export') { + const input = document.createElement('input'); + input.type = 'file'; + input.setAttribute('nwsave', ''); + input.accept = '.dxf,.svg,.pdf,.png,.json'; + input.onchange = () => { + const name = input.value || 'cad-export'; + const ext = name.split('.').pop()?.toLowerCase() as ExportFormat; + if (ext && ['dxf', 'svg', 'pdf', 'png', 'json'].includes(ext)) { + handleExport(ext); + } else { + handleExport('dxf'); + } + }; + input.click(); + return; + } + + // ─── Edit actions ─── + if (action === 'undo') { handleUndo(); return; } + if (action === 'redo') { handleRedo(); return; } + if (action === 'copy') { + const selected = elements.filter((e) => (e as any).selected); + const clip = selected.length > 0 ? selected : elements.slice(0, 1); + clipboardRef.current = clip; + setCommandHistory((prev) => [...prev, { prefix: '·', text: `${clip.length} Element(e) kopiert`, type: 'info' }]); + return; + } + if (action === 'paste') { + if (clipboardRef.current && clipboardRef.current.length > 0) { + const offset = 20; + const pasted = clipboardRef.current.map((el, i) => ({ + ...el, + id: `el-${Date.now()}-${i}`, + x: (el as any).x ? (el as any).x + offset : undefined, + y: (el as any).y ? (el as any).y + offset : undefined, + })); + setElements((prev) => [...prev, ...pasted]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `${pasted.length} Element(e) eingefügt`, type: 'info' }]); + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zwischenablage leer', type: 'info' }]); + } + return; + } + + // ─── Insert actions (set active tool) ─── + if (action === 'insert-line') { setActiveTool('line'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Linie-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-rect') { setActiveTool('rect'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rechteck-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-circle') { setActiveTool('circle'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Kreis-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-text') { setActiveTool('text'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Text-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-freehand') { setActiveTool('polyline'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Freihand-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-image') { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = () => { + if (input.files && input.files[0]) { + const reader = new FileReader(); + reader.onload = () => { + const imgEl: CADElement = { + id: `el-${Date.now()}`, + type: 'image' as any, + layerId: activeLayerId, + x: 0, y: 0, + properties: { src: reader.result as string, width: 200, height: 200 }, + } as any; + setElements((prev) => [...prev, imgEl]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Bild eingefügt', type: 'info' }]); + }; + reader.readAsDataURL(input.files[0]); + } + }; + input.click(); + return; + } + + // ─── Format actions ─── + if (action.startsWith('format-')) { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Format: ${action.replace('format-', '')} (Auswahl erforderlich)`, type: 'info' }]); + return; + } + + // ─── View actions ─── + if (action === 'zoom-fit') { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]); + return; + } + if (action === 'zoom-100') { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: 100%', type: 'info' }]); + return; + } + if (action === 'grid') { + setGridEnabled((p) => !p); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Grid ${gridEnabled ? 'aus' : 'ein'}`, type: 'info' }]); + return; + } + if (action === 'layer') { setActiveRightPanel('layer'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Layer-Manager geöffnet', type: 'info' }]); return; } + + // ─── Tools actions ─── + if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; } + if (action === 'search') { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Suche: Befehlszeile verwenden (Strg+F)', type: 'info' }]); + return; + } + if (action === 'history') { setHistoryPanelOpen((prev) => !prev); return; } + + // ─── Background ─── + if (action === 'background-import') { setBgImportOpen(true); return; } + + // ─── KI actions ─── + if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; } + if (action === 'ki-draw') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Zeichnen: Beschreibung eingeben', type: 'info' }]); return; } + if (action === 'ki-analyze') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Analyse gestartet', type: 'info' }]); return; } + }, [handleUndo, handleRedo, handleImport, handleExport, onNavigateBack, drawingId, token, elements, activeLayerId, gridEnabled]); + + const handleToolChange = useCallback((tool: string) => { + setActiveTool(tool); + setMobileLeftOpen(false); + }, []); + + const handleViewChange = useCallback((mode: ViewMode) => { + setViewMode(mode); + }, []); + + const handleToggleGrid = useCallback(() => setGridEnabled((p) => !p), []); + const handleToggleOrtho = useCallback(() => setOrthoEnabled((p) => !p), []); + const handleToggleSnap = useCallback(() => setSnapEnabled((p) => !p), []); + const handleTogglePolar = useCallback(() => setPolarEnabled((p) => !p), []); + + // Layer handlers + const handleSelectLayer = useCallback((id: string) => setActiveLayerId(id), []); + const handleAddLayer = useCallback(() => { + setLayers((prev) => { + const newId = `layer-${Date.now()}`; + const newLayer: CADLayer = { + id: newId, name: `Layer ${prev.length + 1}`, visible: true, locked: false, + color: '#ffffff', lineType: 'solid', transparency: 0, + sortOrder: prev.length, parentId: null, + }; + // Save to backend + if (drawingId && token) { + createLayerTyped(token, drawingId, newLayer).catch((err) => { + console.error('Failed to save layer:', err); + }); + } + return [...prev, newLayer]; + }); + }, [drawingId, token]); + const handleToggleLayer = useCallback((id: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, visible: !l.visible } : l)); + }, []); + const handleDeleteLayer = useCallback((id: string) => { + setLayers((prev) => prev.filter((l) => l.id !== id)); + // Delete from backend + if (token) { + apiDeleteLayer(token, id).catch((err) => { + console.error('Failed to delete layer:', err); + }); + } + }, [token]); + const handleRenameLayer = useCallback((id: string, name: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, name } : l)); + }, []); + const handleDuplicateLayer = useCallback((id: string) => { + setLayers((prev) => { + const layer = prev.find((l) => l.id === id); + if (!layer) return prev; + const newId = `layer-${Date.now()}`; + const copy: CADLayer = { ...layer, id: newId, name: `${layer.name} (Kopie)`, sortOrder: prev.length }; + return [...prev, copy]; + }); + }, []); + const handleToggleLock = useCallback((id: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, locked: !l.locked } : l)); + }, []); + const handleUpdateLayerColor = useCallback((id: string, color: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, color } : l)); + }, []); + const handleUpdateLayerLineType = useCallback((id: string, lineType: 'solid' | 'dashed' | 'dotted') => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, lineType } : l)); + }, []); + const handleUpdateLayerTransparency = useCallback((id: string, transparency: number) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, transparency } : l)); + }, []); + + const handleZoomIn = useCallback(() => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: vergrößert', type: 'info' }]); + }, []); + const handleZoomOut = useCallback(() => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: verkleinert', type: 'info' }]); + }, []); + const handleZoomFit = useCallback(() => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]); + }, []); + + const handleBgApply = useCallback((config: BackgroundConfig, _image: HTMLImageElement | null) => { + setBgConfig(config); + setBgImportOpen(false); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Hintergrund geladen: ${config.name} (${config.width}×${config.height}px, Maßstab: ${config.scale.toFixed(3)} px/mm)`, type: 'info' }]); + }, []); + + const handleCommand = useCallback((cmd: string) => { + const upper = cmd.trim().toUpperCase(); + setCommandHistory((prev) => [...prev, { prefix: '›', text: cmd, type: 'command' }]); + + const registry = getCommandRegistry(); + + if (upper === 'UNDO' || upper === 'U') { + handleUndo(); + return; + } + if (upper === 'REDO') { + handleRedo(); + return; + } + + // Group command: create group from currently selected elements + if (upper === 'GROUP' || upper === 'GRP') { + const gm = groupManagerRef.current; + // For now, group all elements (selection state is in InteractionEngine, not accessible here) + // In a full implementation, we'd need selected IDs from the interaction engine + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe erstellt (Auswahl im Canvas erforderlich)', type: 'info' }]); + setGroups(gm.getGroups()); + return; + } + + // Ungroup command + if (upper === 'UNG' || upper === 'UNGROUP') { + const gm = groupManagerRef.current; + const allGroups = gm.getGroups(); + if (allGroups.length > 0) { + gm.ungroup(allGroups[allGroups.length - 1].id); + setGroups(gm.getGroups()); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe aufgelöst', type: 'info' }]); + } + return; + } + + // Import command — trigger file dialog + if (upper === 'IMPORT' || upper === 'IMP' || upper === 'I') { + handleRibbonAction('import'); + return; + } + // Export command — trigger export dialog + if (upper === 'EXPORT' || upper === 'EXP' || upper === 'EX') { + handleRibbonAction('export'); + return; + } + + const tool = registry.getToolId(upper); + if (tool) { + setActiveTool(tool); + setMobileLeftOpen(false); + const label = registry.getLabel(upper) ?? `Werkzeug: ${tool}`; + setCommandHistory((prev) => [...prev, { prefix: '·', text: label, type: 'info' }]); + } else { + // Check plugin commands + const pluginCmds = pluginRegistry.getCommandExtensions(); + const parts = cmd.trim().split(/\s+/); + const cmdName = parts[0].toUpperCase(); + const pluginCmd = pluginCmds.find((c) => c.name.toUpperCase() === cmdName); + if (pluginCmd) { + const ctx: PluginContext = { + addElement: (el) => setElements((prev) => [...prev, el]), + removeElement: (id) => setElements((prev) => prev.filter((e) => e.id !== id)), + updateElement: (id, props) => setElements((prev) => prev.map((e) => (e.id === id ? { ...e, ...props } : e))), + getElements: () => elements, + getLayers: () => layers, + getActiveLayerId: () => activeLayerId, + showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]), + log: (msg) => console.log(`[Plugin] ${msg}`), + }; + pluginCmd.execute(parts.slice(1), ctx); + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Unbekannter Befehl: ${cmd}`, type: 'info' }]); + } + } + }, [handleUndo, handleRedo, handleRibbonAction]); + + const handleKISend = useCallback(async (text: string) => { + const userMsg: KIMessage = { id: `ki-${Date.now()}`, role: 'user', content: text }; + const pendingId = `ki-${Date.now() + 1}`; + const pendingMsg: KIMessage = { id: pendingId, role: 'assistant', content: '…' }; + setKIMessages((prev) => [...prev, userMsg, pendingMsg]); + setKiLoading(true); + + try { + // Build CAD context + const elementTypeSummary: Record = {}; + for (const el of elements) { + elementTypeSummary[el.type] = (elementTypeSummary[el.type] || 0) + 1; + } + const context = { + projectName, + elementCount: elements.length, + layerCount: layers.length, + elementTypeSummary, + }; + + // Build message history (last 10 messages) + const history = kiMessages.slice(-10).map((m) => ({ + role: m.role, + content: typeof m.content === 'string' ? m.content : String(m.content), + })); + history.push({ role: 'user', content: text }); + + const result = await aiChat(token, history, context); + + setKIMessages((prev) => + prev.map((m) => + m.id === pendingId + ? { ...m, content: result.content } + : m + ) + ); + } catch (err: any) { + setKIMessages((prev) => + prev.map((m) => + m.id === pendingId + ? { ...m, content: `Fehler: ${err.message || 'KI-Anfrage fehlgeschlagen'}` } + : m + ) + ); + } finally { + setKiLoading(false); + } + }, [token, projectName, elements, layers, kiMessages]); + + const handleSuggestionClick = useCallback((suggestion: KISuggestion) => { + handleKISend(suggestion.label); + }, [handleKISend]); + + const activeLayerName = layers.find((l) => l.id === 'layer-0')?.name ?? '—'; + + // ─── Render ───────────────────────────────────────────── + return ( +
+ + +
+ + + +
+ + + setMobileLeftOpen(false)} + onCloseRight={() => setMobileRightOpen(false)} + onRightTabChange={setActiveDrawerTab} + /> + setBgImportOpen(false)} + onApply={handleBgApply} + backgroundService={bgServiceRef.current} + /> + {historyPanelOpen && ( + { + const snap = historyManagerRef.current.jumpTo(entryId); + if (snap) { + restoreSnapshot(snap); + syncHistory(); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Historie: zu „${snap.label}“ gesprungen`, type: 'info' }]); + } + }} + onClose={() => setHistoryPanelOpen(false)} + /> + )} +
+ ); +}; + +// ─── App Wrapper (Auth Gate) ─────────────────────────── +const App: React.FC = () => { + const { user, token } = useAuth(); + const [authView, setAuthView] = useState<'login' | 'register'>('login'); + const [openedProjectId, setOpenedProjectId] = useState(null); + + // Not authenticated → show Login or Register + if (!user || !token) { + return authView === 'login' + ? setAuthView('register')} /> + : setAuthView('login')} />; + } + + // Authenticated but no project opened → show Dashboard + if (!openedProjectId) { + return setOpenedProjectId(id)} />; + } + + // Authenticated + project opened → show CAD Editor + return setOpenedProjectId(null)} />; +}; + +export default App; diff --git a/frontend/src/App.tsx.bak b/frontend/src/App.tsx.bak new file mode 100644 index 0000000..47c55bf --- /dev/null +++ b/frontend/src/App.tsx.bak @@ -0,0 +1,1076 @@ +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import type { + Theme, RibbonTab, ViewMode, RightPanel, DrawerTab, + CursorPos, CommandHistoryEntry, KIMessage, KISuggestion, +} from './types/ui.types'; +import type { CADElement, CADLayer, BlockDefinition } from './types/cad.types'; +import { createDefaultBlocks, BlockService } from './services/blockService'; +import { SeatingService } from './services/seatingService'; +import type { ToolState } from './interaction'; +import { GroupManager, type ElementGroup } from './tools/modification/GroupTool'; +import Topbar from './components/Topbar'; +import RibbonBar from './components/RibbonBar'; +import LeftSidebar from './components/LeftSidebar'; +import CanvasArea from './components/CanvasArea'; +import RightSidebar from './components/RightSidebar'; +import CommandLine from './components/CommandLine'; +import StatusBar from './components/StatusBar'; +import MobileDrawers from './components/MobileDrawers'; +import BackgroundImport from './components/BackgroundImport'; +import HistoryPanel from './components/HistoryPanel'; +import { BackgroundService, type BackgroundConfig } from './services/backgroundService'; +import { HistoryManager, type CADStateSnapshot, type HistoryEntry } from './history'; +import { getCommandRegistry } from './services/commandRegistry'; +import { importFile, type ImportResult } from './services/importService'; +import { exportProject, downloadBlob, type ExportFormat } from './services/exportService'; +import type { ProjectData } from './types/cad.types'; +import { loadProjectDataTyped, createElementTyped, updateElement, deleteElement as apiDeleteElement, createLayerTyped, updateLayer, deleteLayer as apiDeleteLayer, createBlockTyped, updateBlock, deleteBlock as apiDeleteBlock, aiChat } from './services/api'; +import { useYjsBinding } from './crdt'; +import { registerBuiltinPlugins, pluginRegistry } from './plugins'; +import type { PluginContext } from './plugins'; +import './styles.css'; +import './styles/auth.css'; +import { useAuth } from './contexts/AuthContext'; +import { Login } from './pages/Login'; +import { Register } from './pages/Register'; +import { Dashboard } from './pages/Dashboard'; + +// ─── Mock Data ────────────────────────────────────────────── +const initialLayers: CADLayer[] = [ + { id: 'layer-0', name: 'Wände', visible: true, locked: false, color: '#e74c3c', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null }, + { id: 'layer-1', name: 'Türen', visible: true, locked: false, color: '#3498db', lineType: 'solid', transparency: 0, sortOrder: 1, parentId: null }, + { id: 'layer-2', name: 'Bestuhlung', visible: true, locked: false, color: '#2ecc71', lineType: 'solid', transparency: 0, sortOrder: 2, parentId: null }, + { id: 'layer-3', name: 'Bühne', visible: true, locked: false, color: '#f39c12', lineType: 'solid', transparency: 0, sortOrder: 3, parentId: null }, + { id: 'layer-4', name: 'Hintergrund', visible: true, locked: true, color: '#95a5a6', lineType: 'dotted', transparency: 50, sortOrder: 4, parentId: null }, +]; + +const initialBlocks: BlockDefinition[] = createDefaultBlocks(); + +const initialCommandHistory: CommandHistoryEntry[] = [ + { prefix: '·', text: 'Bereit · Werkzeug: Auswahl · 110 Objekte · 5 Ebenen', type: 'info' }, + { prefix: '·', text: 'Auto-Save aktiv · letzte Speicherung vor 3 Sekunden', type: 'info' }, +]; + +const initialKIMessages: KIMessage[] = [ + { id: 'ki-1', role: 'assistant', content: 'Hallo! Ich bin der KI Copilot. Wie kann ich helfen?' }, +]; + +const initialKISuggestions: KISuggestion[] = [ + { id: 'sug-1', label: 'Bestuhlung automatisch generieren' }, + { id: 'sug-2', label: 'Maße analysieren' }, + { id: 'sug-3', label: 'Flächen berechnen' }, +]; + +// Prevent duplicate drawing creation across StrictMode double-render +const loadedProjects = new Set(); + +// ─── CAD Editor Component ─────────────────────────────── +interface CADEditorProps { + projectId: string; + token: string; + onNavigateBack: () => void; +} + +const CADEditor: React.FC = ({ projectId, token, onNavigateBack }) => { + // Theme + const [theme, setTheme] = useState('dark'); + + // Auth user for collaboration + const { user } = useAuth(); + + // Yjs collaboration binding + const collab = useYjsBinding({ + docName: `project-${projectId}`, + userId: user?.id || 'anonymous', + userName: user?.name || 'Gast', + userColor: '#3498db', + enabled: true, + }); + + // Project + const [projectName, setProjectName] = useState('Unbenannt'); + const [savedStatus, setSavedStatus] = useState('Lädt…'); + const [drawingId, setDrawingId] = useState(null); + + // Ribbon + const [activeRibbonTab, setActiveRibbonTab] = useState('start'); + + // Tools + const [activeTool, setActiveTool] = useState('select'); + + // Canvas / View + const [viewMode, setViewMode] = useState('2d'); + const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); + const [gridEnabled, setGridEnabled] = useState(true); + const [orthoEnabled, setOrthoEnabled] = useState(false); + const [snapEnabled, setSnapEnabled] = useState(true); + const [polarEnabled, setPolarEnabled] = useState(false); + + // Right sidebar + const [activeRightPanel, setActiveRightPanel] = useState('tool'); + const [selectedElement, setSelectedElement] = useState(null); + const [layers, setLayers] = useState(initialLayers); + const [activeLayerId, setActiveLayerId] = useState('layer-0'); + const [blocks, setBlocks] = useState(initialBlocks); + const [blockCategory, setBlockCategory] = useState('Alle'); + const [selectedTemplate, setSelectedTemplate] = useState(null); + + // Command line + const [commandHistory, setCommandHistory] = useState(initialCommandHistory); + + // KI Copilot + const [kiMessages, setKIMessages] = useState(initialKIMessages); + const [kiSuggestions] = useState(initialKISuggestions); + const [kiLoading, setKiLoading] = useState(false); + + // Plugin system + useEffect(() => { + const ctx: PluginContext = { + addElement: (el) => setElements((prev) => [...prev, el]), + removeElement: (id) => setElements((prev) => prev.filter((e) => e.id !== id)), + updateElement: (id, props) => setElements((prev) => prev.map((e) => (e.id === id ? { ...e, ...props } : e))), + getElements: () => elements, + getLayers: () => layers, + getActiveLayerId: () => activeLayerId, + showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]), + log: (msg) => console.log(`[Plugin] ${msg}`), + }; + pluginRegistry.setContext(ctx); + registerBuiltinPlugins(); + pluginRegistry.initDefaults(); + }, []); + + // Status bar + const onlineCount = collab.cursors.length + 1; + + // Mobile drawers + const [mobileLeftOpen, setMobileLeftOpen] = useState(false); + const [mobileRightOpen, setMobileRightOpen] = useState(false); + const [activeDrawerTab, setActiveDrawerTab] = useState('tool'); + + // Background + const [bgImportOpen, setBgImportOpen] = useState(false); + const [bgConfig, setBgConfig] = useState(null); + const bgServiceRef = React.useRef(new BackgroundService()); + + // History panel + const [historyPanelOpen, setHistoryPanelOpen] = useState(false); + + // Elements + undo/redo (HistoryManager) + const [elements, setElements] = useState([]); + const historyManagerRef = React.useRef(new HistoryManager()); + const [historyEntries, setHistoryEntries] = useState([]); + const [toolState, setToolState] = useState(null); + + const seatCount = useMemo(() => { + const svc = new SeatingService(); + return svc.countSeats(elements).total; + }, [elements]); + + // Groups + const groupManagerRef = React.useRef(new GroupManager()); + const [groups, setGroups] = useState([]); + + // Clipboard (for copy/paste) + const clipboardRef = React.useRef(null); + + // ─── Handlers ─────────────────────────────────────────── + const handleThemeToggle = useCallback(() => { + setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); + }, []); + + const syncHistory = useCallback(() => { + setHistoryEntries(historyManagerRef.current.getHistory()); + }, []); + + const restoreSnapshot = useCallback((snap: CADStateSnapshot) => { + setElements(snap.elements); + setLayers(snap.layers); + setBlocks(snap.blocks); + setGroups(snap.groups); + setBgConfig(snap.bgConfig); + }, []); + + const handleUndo = useCallback(() => { + const snap = historyManagerRef.current.undo(); + if (snap) { + restoreSnapshot(snap); + syncHistory(); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rückgängig: letzte Aktion', type: 'info' }]); + } + }, [restoreSnapshot, syncHistory]); + + const handleRedo = useCallback(() => { + const snap = historyManagerRef.current.redo(); + if (snap) { + restoreSnapshot(snap); + syncHistory(); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Wiederherstellen: letzte Aktion', type: 'info' }]); + } + }, [restoreSnapshot, syncHistory]); + + const pushHistorySnapshot = useCallback((label: string) => { + historyManagerRef.current.pushSnapshot({ + elements, + layers, + blocks, + groups, + bgConfig, + }, label); + syncHistory(); + }, [elements, layers, blocks, groups, bgConfig, syncHistory]); + + // Initialize HistoryManager with initial state on mount + const historyInitRef = React.useRef(false); + React.useEffect(() => { + if (historyInitRef.current) return; + historyInitRef.current = true; + historyManagerRef.current.initialize({ + elements: [], + layers: initialLayers, + blocks: initialBlocks, + groups: [], + bgConfig: null, + }); + syncHistory(); + }, [syncHistory]); + + // Load project data from backend on mount + const [dataLoaded, setDataLoaded] = useState(false); + React.useEffect(() => { + if (!projectId || !token) return; + let cancelled = false; + (async () => { + try { + setSavedStatus('Lädt…'); + const data = await loadProjectDataTyped(token, projectId); + if (cancelled) return; + setProjectName(data.project.name); + setDrawingId(data.drawing?.id || null); + if (data.elements.length > 0) setElements(data.elements); + if (data.layers.length > 0) setLayers(data.layers); + if (data.blocks.length > 0) setBlocks(data.blocks); + // Save initial layers to backend if backend has none + if (data.layers.length === 0 && data.drawing) { + for (const layer of initialLayers) { + createLayerTyped(token, data.drawing.id, layer).catch(() => {}); + } + } + setSavedStatus('gespeichert'); + setDataLoaded(true); + // Push initial data to Yjs CRDT after load + if (collab.status === 'connected') { + collab.loadFromState({ elements: data.elements, layers: data.layers, blocks: data.blocks }); + } + } catch (err) { + console.error('Failed to load project:', err); + setSavedStatus('Fehler beim Laden'); + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId, token]); + + // Sync remote → local: when Yjs data changes from other users, update local state + React.useEffect(() => { + if (collab.status !== 'connected') return; + // Skip if collab data is empty (not yet loaded) + if (collab.elements.length === 0 && collab.layers.length === 0 && collab.blocks.length === 0) return; + + // Only update local state if remote data differs AND local is not ahead of remote + // (prevents race condition where local element is overwritten by stale Yjs sync) + const localIds = new Set(elements.map(e => e.id)); + const remoteIds = new Set(collab.elements.map(e => e.id)); + const localHasExtra = elements.some(e => !remoteIds.has(e.id)); + const remoteHasExtra = collab.elements.some(e => !localIds.has(e.id)); + // Only sync from remote if remote has elements local doesn't have AND local doesn't have unsaved elements + if (remoteHasExtra && !localHasExtra) { + setElements(collab.elements); + } else if (remoteHasExtra && localHasExtra) { + // Merge: keep local elements, update any that changed remotely + const merged = elements.map(el => { + const remote = collab.elements.find(e => e.id === el.id); + return remote || el; + }); + // Add remote-only elements + collab.elements.forEach(el => { if (!localIds.has(el.id)) merged.push(el); }); + setElements(merged); + } + + const sameLayers = collab.layers.length === layers.length && + collab.layers.every((l, i) => l.id === layers[i]?.id); + if (!sameLayers) setLayers(collab.layers); + + const sameBlocks = collab.blocks.length === blocks.length && + collab.blocks.every((b, i) => b.id === blocks[i]?.id); + if (!sameBlocks) setBlocks(collab.blocks); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collab.elements, collab.layers, collab.blocks, collab.status]); + + const handleElementCreated = useCallback((el: CADElement) => { + const elWithLayer = el.layerId ? el : { ...el, layerId: activeLayerId }; + setElements((prev) => { + const newElements = [...prev, elWithLayer]; + historyManagerRef.current.pushSnapshot({ + elements: newElements, layers, blocks, groups, bgConfig, + }, 'Element erstellt'); + return newElements; + }); + syncHistory(); + // Push to Yjs CRDT for real-time sync + collab.setElement(elWithLayer); + // Save to backend + if (drawingId && token) { + setSavedStatus('Speichert…'); + createElementTyped(token, drawingId, elWithLayer).then(() => { + setSavedStatus('gespeichert'); + }).catch((err) => { + console.error('Failed to save element:', err); + setSavedStatus('Fehler beim Speichern'); + }); + } + }, [layers, blocks, groups, bgConfig, syncHistory, drawingId, token, collab, activeLayerId]); + + const handleElementsDeleted = useCallback((ids: string[]) => { + setElements((prev) => { + const newElements = prev.filter((e) => !ids.includes(e.id)); + historyManagerRef.current.pushSnapshot({ + elements: newElements, layers, blocks, groups, bgConfig, + }, `${ids.length} Element(e) gelöscht`); + return newElements; + }); + syncHistory(); + // Push deletions to Yjs CRDT for real-time sync + ids.forEach(id => collab.deleteElement(id)); + // Delete from backend + if (token) { + setSavedStatus('Speichert…'); + Promise.all(ids.map(id => apiDeleteElement(token, id))).then(() => { + setSavedStatus('gespeichert'); + }).catch((err) => { + console.error('Failed to delete elements:', err); + setSavedStatus('Fehler beim Speichern'); + }); + } + }, [elements, layers, blocks, groups, bgConfig, syncHistory, token, collab]); + + const handleElementsModified = useCallback((modified: CADElement[]) => { + setElements((prev) => { + const newElements = prev.map((e) => { + const found = modified.find((m) => m.id === e.id); + return found ?? e; + }); + historyManagerRef.current.pushSnapshot({ + elements: newElements, layers, blocks, groups, bgConfig, + }, `${modified.length} Element(e) geändert`); + return newElements; + }); + syncHistory(); + // Push modifications to Yjs CRDT for real-time sync + modified.forEach(el => collab.setElement(el)); + // Update in backend + if (token) { + setSavedStatus('Speichert…'); + Promise.all(modified.map(el => updateElement(token, el.id, el))).then(() => { + setSavedStatus('gespeichert'); + }).catch((err) => { + console.error('Failed to update elements:', err); + setSavedStatus('Fehler beim Speichern'); + }); + } + }, [layers, blocks, groups, bgConfig, syncHistory, token, collab]); + + const lastCursorUpdateRef = useRef(0); + const handleCursorMoved = useCallback((x: number, y: number) => { + const now = performance.now(); + if (now - lastCursorUpdateRef.current < 33) return; // throttle to ~30fps + lastCursorUpdateRef.current = now; + setCursorPos({ x, y }); + collab.setCursor(x, y); + }, [collab]); + + const handleToolStateChanged = useCallback((state: ToolState) => { + setToolState(state); + }, []); + + const handleTextEdit = useCallback((el: CADElement) => { + const text = window.prompt('Text eingeben:', ''); + if (text !== null && text.trim() !== '') { + setElements((prev) => prev.map((e) => + e.id === el.id ? { ...e, properties: { ...e.properties, text } } : e + )); + } + }, []); + + const handleCommandTrigger = useCallback((msg: string) => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]); + }, []); + + // Block management handlers + const handleRenameBlock = useCallback((id: string, name: string) => { + setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, name } : b)); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block umbenannt: ${name}`, type: 'info' }]); + }, []); + + const handleDuplicateBlock = useCallback((id: string) => { + setBlocks((prev) => { + const block = prev.find((b) => b.id === id); + if (!block) return prev; + const newId = `blk-${Date.now()}`; + const copy: BlockDefinition = { + ...block, + id: newId, + name: `${block.name} (Kopie)`, + elements: block.elements.map((el) => ({ ...el, id: `${el.id}_copy_${Date.now()}` })), + }; + return [...prev, copy]; + }); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block dupliziert', type: 'info' }]); + }, []); + + const handleDeleteBlock = useCallback((id: string) => { + setBlocks((prev) => prev.filter((b) => b.id !== id)); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Block gelöscht', type: 'info' }]); + // Delete from backend + if (token) { + apiDeleteBlock(token, id).catch((err) => { + console.error('Failed to delete block:', err); + }); + } + }, [token]); + + const handleSvgImport = useCallback((svg: string, name: string, category: string) => { + const svc = new BlockService(); + const block = svc.importSVG(svg, name, category); + setBlocks((prev) => [...prev, block]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `SVG importiert: ${name}`, type: 'info' }]); + }, []); + + const handleSaveGroupAsBlock = useCallback((name: string) => { + const selectedEls = selectedElement ? [selectedElement] : []; + setBlocks((prev) => { + const newId = `blk_grp_${Date.now()}`; + const block: BlockDefinition = { + id: newId, + name, + description: 'Aus Auswahl erstellt', + category: 'Custom', + elements: selectedEls.map(el => ({ ...el, id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` })), + }; + return [...prev, block]; + }); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block erstellt: ${name} (${selectedEls.length} Elemente)`, type: 'info' }]); + }, [selectedElement]); + + const handleBlockCategoryChange = useCallback((cat: string) => { + setBlockCategory(cat); + }, []); + + const handleTemplateSelect = useCallback((templateName: string | null) => { + setSelectedTemplate(templateName); + if (templateName) { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Vorlage gewählt: ${templateName} – Klicken zum Platzieren`, type: 'info' }]); + } + }, []); + + const handleBlockSearch = useCallback((_query: string) => { + // Search is handled locally in BlockLibrary component + }, []); + + const handleDragBlock = useCallback((blockId: string) => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block gezogen: ${blockId}`, type: 'info' }]); + }, []); + + const handleBlockDrop = useCallback((blockId: string, x: number, y: number) => { + const svc = new BlockService(); + blocks.forEach(b => svc.addBlock(b)); + const instance = svc.createInstance(blockId, x, y, activeLayerId); + if (instance) { + setElements((prev) => [...prev, instance]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Block platziert: ${blockId} bei (${x.toFixed(2)}, ${y.toFixed(2)})`, type: 'info' }]); + } + }, [blocks, activeLayerId]); + + const handleSelectionChange = useCallback((selectedIds: string[]) => { + if (selectedIds.length === 1) { + const el = elements.find(e => e.id === selectedIds[0]); + setSelectedElement(el ?? null); + } else { + setSelectedElement(null); + } + }, [elements]); + + const handleImport = useCallback(async (file: File) => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiere ${file.name}...`, type: 'info' }]); + const result = await importFile(file); + if (result.success) { + setElements((prev) => [...prev, ...result.elements]); + if (result.layers && result.layers.length > 0) { + setLayers((prev) => { + const existingIds = new Set(prev.map(l => l.id)); + const newLayers = result.layers!.filter(l => !existingIds.has(l.id)); + return [...prev, ...newLayers]; + }); + } + if (result.blocks && result.blocks.length > 0) { + setBlocks((prev) => [...prev, ...result.blocks!]); + } + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Importiert: ${result.elements.length} Elemente aus ${file.name}`, type: 'info' }]); + if (result.warnings.length > 0) { + result.warnings.forEach(w => setCommandHistory((prev) => [...prev, { prefix: '·', text: w, type: 'info' }])); + } + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Import fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]); + } + }, []); + + const handleExport = useCallback(async (format: ExportFormat) => { + const projectData: ProjectData = { + version: '1.0', + name: projectName, + layers, + elements, + blocks, + }; + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiere als ${format.toUpperCase()}...`, type: 'info' }]); + const result = await exportProject(projectData, { format }); + if (result.success && result.blob) { + downloadBlob(result.blob, result.filename); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Exportiert: ${result.filename}`, type: 'info' }]); + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Export fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`, type: 'info' }]); + } + }, [layers, elements, blocks]); + + const handleRibbonAction = useCallback((action: string) => { + setCommandHistory((prev) => [...prev, { prefix: '›', text: action, type: 'command' }]); + + // ─── File actions ─── + if (action === 'new') { + onNavigateBack(); + return; + } + if (action === 'open') { + onNavigateBack(); + return; + } + if (action === 'save') { + setSavedStatus('Gespeichert'); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Projekt gespeichert', type: 'info' }]); + // Save elements to backend if drawingId exists + if (drawingId && token) { + elements.forEach((el) => { + if (!el.id.startsWith('el-')) return; + updateElement(token, el.id, el).catch(() => {}); + }); + } + return; + } + if (action === 'import') { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.dxf,.svg,.json'; + input.onchange = () => { + if (input.files && input.files[0]) { + handleImport(input.files[0]); + } + }; + input.click(); + return; + } + if (action === 'export') { + const input = document.createElement('input'); + input.type = 'file'; + input.setAttribute('nwsave', ''); + input.accept = '.dxf,.svg,.pdf,.png,.json'; + input.onchange = () => { + const name = input.value || 'cad-export'; + const ext = name.split('.').pop()?.toLowerCase() as ExportFormat; + if (ext && ['dxf', 'svg', 'pdf', 'png', 'json'].includes(ext)) { + handleExport(ext); + } else { + handleExport('dxf'); + } + }; + input.click(); + return; + } + + // ─── Edit actions ─── + if (action === 'undo') { handleUndo(); return; } + if (action === 'redo') { handleRedo(); return; } + if (action === 'copy') { + const selected = elements.filter((e) => (e as any).selected); + const clip = selected.length > 0 ? selected : elements.slice(0, 1); + clipboardRef.current = clip; + setCommandHistory((prev) => [...prev, { prefix: '·', text: `${clip.length} Element(e) kopiert`, type: 'info' }]); + return; + } + if (action === 'paste') { + if (clipboardRef.current && clipboardRef.current.length > 0) { + const offset = 20; + const pasted = clipboardRef.current.map((el, i) => ({ + ...el, + id: `el-${Date.now()}-${i}`, + x: (el as any).x ? (el as any).x + offset : undefined, + y: (el as any).y ? (el as any).y + offset : undefined, + })); + setElements((prev) => [...prev, ...pasted]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `${pasted.length} Element(e) eingefügt`, type: 'info' }]); + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zwischenablage leer', type: 'info' }]); + } + return; + } + + // ─── Insert actions (set active tool) ─── + if (action === 'insert-line') { setActiveTool('line'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Linie-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-rect') { setActiveTool('rect'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rechteck-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-circle') { setActiveTool('circle'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Kreis-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-text') { setActiveTool('text'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Text-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-freehand') { setActiveTool('polyline'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Freihand-Werkzeug aktiv', type: 'info' }]); return; } + if (action === 'insert-image') { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = () => { + if (input.files && input.files[0]) { + const reader = new FileReader(); + reader.onload = () => { + const imgEl: CADElement = { + id: `el-${Date.now()}`, + type: 'image' as any, + layerId: activeLayerId, + x: 0, y: 0, + properties: { src: reader.result as string, width: 200, height: 200 }, + } as any; + setElements((prev) => [...prev, imgEl]); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Bild eingefügt', type: 'info' }]); + }; + reader.readAsDataURL(input.files[0]); + } + }; + input.click(); + return; + } + + // ─── Format actions ─── + if (action.startsWith('format-')) { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Format: ${action.replace('format-', '')} (Auswahl erforderlich)`, type: 'info' }]); + return; + } + + // ─── View actions ─── + if (action === 'zoom-fit') { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]); + return; + } + if (action === 'zoom-100') { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: 100%', type: 'info' }]); + return; + } + if (action === 'grid') { + setGridEnabled((p) => !p); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Grid ${gridEnabled ? 'aus' : 'ein'}`, type: 'info' }]); + return; + } + if (action === 'layer') { setActiveRightPanel('layer'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Layer-Manager geöffnet', type: 'info' }]); return; } + + // ─── Tools actions ─── + if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; } + if (action === 'search') { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Suche: Befehlszeile verwenden (Strg+F)', type: 'info' }]); + return; + } + if (action === 'plugins') { setActiveRightPanel('plugins'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Plugin-Manager geöffnet', type: 'info' }]); return; } + if (action === 'history') { setHistoryPanelOpen((prev) => !prev); return; } + + // ─── Background ─── + if (action === 'background-import') { setBgImportOpen(true); return; } + + // ─── KI actions ─── + if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; } + if (action === 'ki-draw') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Zeichnen: Beschreibung eingeben', type: 'info' }]); return; } + if (action === 'ki-analyze') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Analyse gestartet', type: 'info' }]); return; } + }, [handleUndo, handleRedo, handleImport, handleExport, onNavigateBack, drawingId, token, elements, activeLayerId, gridEnabled]); + + const handleToolChange = useCallback((tool: string) => { + setActiveTool(tool); + setMobileLeftOpen(false); + }, []); + + const handleViewChange = useCallback((mode: ViewMode) => { + setViewMode(mode); + }, []); + + const handleToggleGrid = useCallback(() => setGridEnabled((p) => !p), []); + const handleToggleOrtho = useCallback(() => setOrthoEnabled((p) => !p), []); + const handleToggleSnap = useCallback(() => setSnapEnabled((p) => !p), []); + const handleTogglePolar = useCallback(() => setPolarEnabled((p) => !p), []); + + // Layer handlers + const handleSelectLayer = useCallback((id: string) => setActiveLayerId(id), []); + const handleAddLayer = useCallback(() => { + setLayers((prev) => { + const newId = `layer-${Date.now()}`; + const newLayer: CADLayer = { + id: newId, name: `Layer ${prev.length + 1}`, visible: true, locked: false, + color: '#ffffff', lineType: 'solid', transparency: 0, + sortOrder: prev.length, parentId: null, + }; + // Save to backend + if (drawingId && token) { + createLayerTyped(token, drawingId, newLayer).catch((err) => { + console.error('Failed to save layer:', err); + }); + } + return [...prev, newLayer]; + }); + }, [drawingId, token]); + const handleToggleLayer = useCallback((id: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, visible: !l.visible } : l)); + }, []); + const handleDeleteLayer = useCallback((id: string) => { + setLayers((prev) => prev.filter((l) => l.id !== id)); + // Delete from backend + if (token) { + apiDeleteLayer(token, id).catch((err) => { + console.error('Failed to delete layer:', err); + }); + } + }, [token]); + const handleRenameLayer = useCallback((id: string, name: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, name } : l)); + }, []); + const handleDuplicateLayer = useCallback((id: string) => { + setLayers((prev) => { + const layer = prev.find((l) => l.id === id); + if (!layer) return prev; + const newId = `layer-${Date.now()}`; + const copy: CADLayer = { ...layer, id: newId, name: `${layer.name} (Kopie)`, sortOrder: prev.length }; + return [...prev, copy]; + }); + }, []); + const handleToggleLock = useCallback((id: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, locked: !l.locked } : l)); + }, []); + const handleUpdateLayerColor = useCallback((id: string, color: string) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, color } : l)); + }, []); + const handleUpdateLayerLineType = useCallback((id: string, lineType: 'solid' | 'dashed' | 'dotted') => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, lineType } : l)); + }, []); + const handleUpdateLayerTransparency = useCallback((id: string, transparency: number) => { + setLayers((prev) => prev.map((l) => l.id === id ? { ...l, transparency } : l)); + }, []); + + const handleZoomIn = useCallback(() => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: vergrößert', type: 'info' }]); + }, []); + const handleZoomOut = useCallback(() => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: verkleinert', type: 'info' }]); + }, []); + const handleZoomFit = useCallback(() => { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]); + }, []); + + const handleBgApply = useCallback((config: BackgroundConfig, _image: HTMLImageElement | null) => { + setBgConfig(config); + setBgImportOpen(false); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Hintergrund geladen: ${config.name} (${config.width}×${config.height}px, Maßstab: ${config.scale.toFixed(3)} px/mm)`, type: 'info' }]); + }, []); + + const handleCommand = useCallback((cmd: string) => { + const upper = cmd.trim().toUpperCase(); + setCommandHistory((prev) => [...prev, { prefix: '›', text: cmd, type: 'command' }]); + + const registry = getCommandRegistry(); + + if (upper === 'UNDO' || upper === 'U') { + handleUndo(); + return; + } + if (upper === 'REDO') { + handleRedo(); + return; + } + + // Group command: create group from currently selected elements + if (upper === 'GROUP' || upper === 'GRP') { + const gm = groupManagerRef.current; + // For now, group all elements (selection state is in InteractionEngine, not accessible here) + // In a full implementation, we'd need selected IDs from the interaction engine + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe erstellt (Auswahl im Canvas erforderlich)', type: 'info' }]); + setGroups(gm.getGroups()); + return; + } + + // Ungroup command + if (upper === 'UNG' || upper === 'UNGROUP') { + const gm = groupManagerRef.current; + const allGroups = gm.getGroups(); + if (allGroups.length > 0) { + gm.ungroup(allGroups[allGroups.length - 1].id); + setGroups(gm.getGroups()); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe aufgelöst', type: 'info' }]); + } + return; + } + + // Import command — trigger file dialog + if (upper === 'IMPORT' || upper === 'IMP' || upper === 'I') { + handleRibbonAction('import'); + return; + } + // Export command — trigger export dialog + if (upper === 'EXPORT' || upper === 'EXP' || upper === 'EX') { + handleRibbonAction('export'); + return; + } + + const tool = registry.getToolId(upper); + if (tool) { + setActiveTool(tool); + setMobileLeftOpen(false); + const label = registry.getLabel(upper) ?? `Werkzeug: ${tool}`; + setCommandHistory((prev) => [...prev, { prefix: '·', text: label, type: 'info' }]); + } else { + // Check plugin commands + const pluginCmds = pluginRegistry.getCommandExtensions(); + const parts = cmd.trim().split(/\s+/); + const cmdName = parts[0].toUpperCase(); + const pluginCmd = pluginCmds.find((c) => c.name.toUpperCase() === cmdName); + if (pluginCmd) { + const ctx: PluginContext = { + addElement: (el) => setElements((prev) => [...prev, el]), + removeElement: (id) => setElements((prev) => prev.filter((e) => e.id !== id)), + updateElement: (id, props) => setElements((prev) => prev.map((e) => (e.id === id ? { ...e, ...props } : e))), + getElements: () => elements, + getLayers: () => layers, + getActiveLayerId: () => activeLayerId, + showToast: (msg) => setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]), + log: (msg) => console.log(`[Plugin] ${msg}`), + }; + pluginCmd.execute(parts.slice(1), ctx); + } else { + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Unbekannter Befehl: ${cmd}`, type: 'info' }]); + } + } + }, [handleUndo, handleRedo, handleRibbonAction]); + + const handleKISend = useCallback(async (text: string) => { + const userMsg: KIMessage = { id: `ki-${Date.now()}`, role: 'user', content: text }; + const pendingId = `ki-${Date.now() + 1}`; + const pendingMsg: KIMessage = { id: pendingId, role: 'assistant', content: '…' }; + setKIMessages((prev) => [...prev, userMsg, pendingMsg]); + setKiLoading(true); + + try { + // Build CAD context + const elementTypeSummary: Record = {}; + for (const el of elements) { + elementTypeSummary[el.type] = (elementTypeSummary[el.type] || 0) + 1; + } + const context = { + projectName, + elementCount: elements.length, + layerCount: layers.length, + elementTypeSummary, + }; + + // Build message history (last 10 messages) + const history = kiMessages.slice(-10).map((m) => ({ + role: m.role, + content: typeof m.content === 'string' ? m.content : String(m.content), + })); + history.push({ role: 'user', content: text }); + + const result = await aiChat(token, history, context); + + setKIMessages((prev) => + prev.map((m) => + m.id === pendingId + ? { ...m, content: result.content } + : m + ) + ); + } catch (err: any) { + setKIMessages((prev) => + prev.map((m) => + m.id === pendingId + ? { ...m, content: `Fehler: ${err.message || 'KI-Anfrage fehlgeschlagen'}` } + : m + ) + ); + } finally { + setKiLoading(false); + } + }, [token, projectName, elements, layers, kiMessages]); + + const handleSuggestionClick = useCallback((suggestion: KISuggestion) => { + handleKISend(suggestion.label); + }, [handleKISend]); + + const activeLayerName = layers.find((l) => l.id === 'layer-0')?.name ?? '—'; + + // ─── Render ───────────────────────────────────────────── + return ( +
+ + +
+ + + +
+ + + setMobileLeftOpen(false)} + onCloseRight={() => setMobileRightOpen(false)} + onRightTabChange={setActiveDrawerTab} + /> + setBgImportOpen(false)} + onApply={handleBgApply} + backgroundService={bgServiceRef.current} + /> + {historyPanelOpen && ( + { + const snap = historyManagerRef.current.jumpTo(entryId); + if (snap) { + restoreSnapshot(snap); + syncHistory(); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Historie: zu „${snap.label}“ gesprungen`, type: 'info' }]); + } + }} + onClose={() => setHistoryPanelOpen(false)} + /> + )} +
+ ); +}; + +// ─── App Wrapper (Auth Gate) ─────────────────────────── +const App: React.FC = () => { + const { user, token } = useAuth(); + const [authView, setAuthView] = useState<'login' | 'register'>('login'); + const [openedProjectId, setOpenedProjectId] = useState(null); + + // Not authenticated → show Login or Register + if (!user || !token) { + return authView === 'login' + ? setAuthView('register')} /> + : setAuthView('login')} />; + } + + // Authenticated but no project opened → show Dashboard + if (!openedProjectId) { + return setOpenedProjectId(id)} />; + } + + // Authenticated + project opened → show CAD Editor + return setOpenedProjectId(null)} />; +}; + +export default App; diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 zcmV+cGycqpP)V|)f$;Qooc7=_G zlYe)HToTQIc!$)^+J1M1y0*T%w!p~7%ux`!eRhO?c80XDxKQ*R^lUUMnA>6NT^?feoZ8xxvP32D&s-9ow zqjcM}eesrC)NeDmsf)*P7wJ|K!&xP%Zy4iI8lF)Tv2!reW)tCzg_1=PmOwd1SQfxa z8;58t!=z~Ba7CYlNWVG>he8aRPY|+-JmozNhn!#9i#77Aa_Edt$ijyCWL#=~I>~2X zZNrQ8I0=D+NWD4pq=7~(i zhfThMNw|G>g^y9pGzxX7ZSApl@tIxFcs{p#MX{Ax&XZT+cR#U+OWc@S)pkIuI}dzu zH?^Q=<(y&Vq-oxSLfc0Zmq81bjZWf}RnssBaD6}2g-XJHLcN_|*IOu>m|x$nbm(?E zyNy!Zp=RroS;?Vg*kmoJYBi!n5{_^@rA!)=t#a^;N$8GL!*DsQb}`yvEuX!G@||An znOfUZAevPrkV_qjl|<~3QRZzG&h@C9Y5z zqpNH4xqbF_InIPh)kX}Vn^5kyed|mOuq+2>M;v~KO37a#yrEn3XDqtOl=rc6_KZ!; zreo)DFVB4|>1Zd(bvMI%8uM;3!)YMYu&cG?(PE!B~y@3yKBMt|R zAf=I16tFwPsl)!jDqvYkLHaAQ+f@W1m6F5aZvwhm4JL z{_l)@b;)mDSzle2gyFP5-r1x-5X{G}ot%VyWP@vEW80!Q=f%RTfpg>B*TA^pyWYUQ z<=xPtz}WcZ!;rFl4m1D&FFHv?K~#9!?A%+fn=lXt;9!Fc#kQ;zk~gZFsH z8e5iu@c_pzX&qb8&Dum*oXwB+fm6l6gFfC|o*wgEiy6tw~&co z9Vd_4)P%wP-KwQW7|lN-znGK#?N+j24U=$982myIBM+vsiKsc*@4-rwJxuAaHKna6 zT3wi!C~a4ZKH03qU}_1bKyx0&$CaK7_%Z+Kl$)fF5^op zZApQF2TvDav!s|krTjw-8US6ep z%!VmX4luub+fseQz_D9ATJQ?iQQwD}TZz{-yo#l12a%+7bT@E(X-hyaVS-5vuXc#^ zx^w;L21;NphGVoj*{s3f4dme0y2LC=G1-7THd`#z?;tuC{^9k(dM{Rf2GOxg7Jzho z7nSZHl7?M9kdalX`)YgoKEfiae5+;$(OGeN1eqxrv!ZCVKyH>xiyNqfe8xzY8*7)H zQls8KMp)F4D>ED;idMOU^^WhVF@q>ZSmeB0y~qC~|DB648hr%Sh|*T(4q|w2l?m2+ zvBVw3@7+Mz?^Yc#+se6KM;a<=(W-I>k)$-qL2V*t}VaW`;?P4)WqI%maIDq8!oUcSYAD`}wWjkSyAVsnF65#2zQ zZ>(K*TlS(E#4y$4Zq+e^_&}d)q20hCe3!LfLYP%nQpLJ~gM6a1hJlz3)aS<9C9me| zAcmJ#>tOwBy{HoP0Sm1&_(E+S@6 zgBIFUoei8zJmdpiq8q5=OY7t@`)JWxn_&GvKVr=Zdb_pEL_j|=?f;WK^U9Q0efd#K z9q7SfJTl4pmA$jsZ5oK8@O9#!I3Cv-kL)<8SalSsp#dcpvJ}Nz#G6FC0%9|7Fi#8; zGDJXtj!&GljT3*HE@0EE>G8Se&d)*nkqe}-?`3vPl&UqK?xG z!3XJ4M-x`EuQjhBbu?ik-)rmIt=DF_N?TVMP)8Gjn)TZ2V%H|zENbeix}kOxd@0}Q z>)HuH6Ean!uS#~4g2Ne2WsMGel|h%j9*W_quQheG^JqmKhc*RYzp0wKlGjBq2VzY_ zgOv8WC1+%W=W)k)Yp_`8kfE=uiiwOZTXi8Uj9YGr$f@yJcJ;#&-Nq~sJ7anE(@;QN z=~br%7%7`isKStX|7!1?L(apl^QvPKlrHV4S+6tNVQ*R1iGdC~WMNE1$a+=rpQmcB z>wxiLIBvOnm;u*;9Y!kJdy(T4lk|8>JAm(&wEsFIF1$_*{>2ZNd$V6DS=SfrGxAv0 zzKe377JI`&o9Ljr+VnS*EwehA{f&{cKZF(6*MG5!p5MvrFA3ll{fmRG*L@6^cb;o^ z3Wm8c?Sc6$`>~VEWw(c$Y?nRO;2Q$=ulpqPtM^=1IZx;@xK0PgO7rKQ^WHVLwtgUT z%|JF{^f(VH)wLKQ%dYiu2RmchBdxL0-M?wxxul_z*{h6ZZ`>-k(vizs((vW8Lt6Z6 zY;Dt?@JWyN`O`f;&d1Mb?e%9oyRK1ql?EE5XB2(W)|D1~Rx35$H6@6)$F?)7V|zEO zI}fu0-0}8W5=6sg$fPnZ~7=tTudl?Ecb@pxbo)vni%gP-?hL|%*?62C;x6?@E`VRnJv z?fTb;k4x;TS7Cu-z%J}uy}e-pwpLQ17Q@4DC+FCdAmNKklG$`I_pyw7E{fYmw~{Fj zi?6KcVy=Wrel)EB_DWO|0CKmI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8WL0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e+bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKcw$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(%KtuV^zC&Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5Mo-0$pkyV3VV4B@Qms46M zuBxGRV@HxU7Wwx-6CB zaU*HO<_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#<Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XClkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn=Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>`zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1R$C6ja5!^ZGh;YRhhxs58qJWo9@Bceac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=RNC6nE=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-NueqTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<11$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdNK8ZDKZ?QFLU?zh30G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsbob0{xu@0TB_*>G7w0ICn zr#VoBktqHZ~XxhiKD*lcG|b;H*|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn&E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fRZa#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9& zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g(lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!wUA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?uf$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI)-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr>)f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p= z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z>vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=Gp_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQA zvqp;$kmGJY>lLsN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^&#TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpYR}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+PmNABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx|$S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6}_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh+g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt!MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLjlm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?&Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/frontend/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/frontend/src/canvas/LayerManager.ts b/frontend/src/canvas/LayerManager.ts new file mode 100644 index 0000000..85f6ecd --- /dev/null +++ b/frontend/src/canvas/LayerManager.ts @@ -0,0 +1,195 @@ +import type { CADLayer } from '../types/cad.types'; + +export class LayerManager { + private layers: Map = 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): 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 { + const buildTree = (parentId: string | null): Array => { + 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; + } +} diff --git a/frontend/src/canvas/RenderEngine.ts b/frontend/src/canvas/RenderEngine.ts new file mode 100644 index 0000000..2a841c1 --- /dev/null +++ b/frontend/src/canvas/RenderEngine.ts @@ -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; + 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): void { + this.options = { ...this.options, ...opts }; + } + + getOptions(): RenderOptions { + return { ...this.options }; + } + + setSelection(sel: Partial): 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 = 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; + }); + } +} diff --git a/frontend/src/canvas/SelectionEngine.ts b/frontend/src/canvas/SelectionEngine.ts new file mode 100644 index 0000000..77f9afd --- /dev/null +++ b/frontend/src/canvas/SelectionEngine.ts @@ -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 = 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): void { + this.options = { ...this.options, ...opts }; + } + + getOptions(): SelectionOptions { + return { ...this.options }; + } + + getSelectedIds(): Set { + 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)[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(); + 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; + } +} diff --git a/frontend/src/canvas/SnapEngine.ts b/frontend/src/canvas/SnapEngine.ts new file mode 100644 index 0000000..22a22e1 --- /dev/null +++ b/frontend/src/canvas/SnapEngine.ts @@ -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; + 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) { + this.config = { + enabled: true, + modes: new Set(['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): 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 = { + 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' }; + } +} diff --git a/frontend/src/canvas/SpatialIndex.ts b/frontend/src/canvas/SpatialIndex.ts new file mode 100644 index 0000000..c440a94 --- /dev/null +++ b/frontend/src/canvas/SpatialIndex.ts @@ -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; + + constructor() { + this.tree = new RBush(); + } + + 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, + }; + } +} diff --git a/frontend/src/canvas/ZoomPanController.ts b/frontend/src/canvas/ZoomPanController.ts new file mode 100644 index 0000000..0cfed9e --- /dev/null +++ b/frontend/src/canvas/ZoomPanController.ts @@ -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; + } +} diff --git a/frontend/src/components/BackgroundImport.tsx b/frontend/src/components/BackgroundImport.tsx new file mode 100644 index 0000000..f07c76d --- /dev/null +++ b/frontend/src/components/BackgroundImport.tsx @@ -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 = ({ open, onClose, onApply, backgroundService }) => { + const [config, setConfig] = useState({ ...DEFAULT_BACKGROUND }); + const [previewUrl, setPreviewUrl] = useState(''); + 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(null); + const previewCanvasRef = useRef(null); + + const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { + 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) => { + 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) => { + backgroundService.updateConfig(partial); + setConfig(backgroundService.getConfig()); + }, [backgroundService]); + + if (!open) return null; + + return ( +
+
+

Hintergrund importieren

+ + {error && ( +
{error}
+ )} + + {/* File Upload */} +
+ + + + {config.name && ( + {config.name} ({config.width}×{config.height}px) + )} +
+ + {/* Preview */} + {previewUrl && ( +
+ +
+ )} + + {/* Calibration */} + {previewUrl && ( +
+
Maßstabs-Kalibrierung
+ {!calibrationMode ? ( + + ) : ( +
+

+ Klicken Sie auf zwei Punkte mit bekanntem Abstand im Bild. +

+ {calibPoint1 && !calibPoint2 &&

Erster Punkt gesetzt. Bitte zweiten Punkt klicken.

} + {calibPoint1 && calibPoint2 && ( +
+ setRealDistance(e.target.value)} + style={{ width: '120px', padding: '4px 8px', fontSize: '13px' }} + /> + + + +
+ )} +
+ )} + {config.scale !== 1 && ( +

+ Aktueller Maßstab: {config.scale.toFixed(2)} px/mm +

+ )} +
+ )} + + {/* Controls */} + {previewUrl && ( +
+ {/* Opacity */} + + updateConfig({ opacity: parseFloat(e.target.value) })} + style={{ width: '100%', marginBottom: '12px' }} + /> + + {/* Position */} +
+ + +
+ + {/* Rotation */} + + updateConfig({ rotation: parseFloat(e.target.value) })} + style={{ width: '100%', marginBottom: '12px' }} + /> + + {/* Scale */} + + updateConfig({ scale: parseFloat(e.target.value) || 1 })} + style={{ width: '120px', padding: '4px' }} + /> + + {/* Visibility */} + +
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +}; + +export default BackgroundImport; diff --git a/frontend/src/components/BlockLibrary.jsx b/frontend/src/components/BlockLibrary.jsx deleted file mode 100644 index 617bbb8..0000000 --- a/frontend/src/components/BlockLibrary.jsx +++ /dev/null @@ -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
Lade...
; - - return ( -
- {/* Category filter */} -
- {categories.map(cat => ( - - ))} -
- {/* Block grid */} -
- {filteredBlocks.map(block => ( -
handleDragStart(e, block)} - style={{ - border: '1px solid #ddd', - borderRadius: 4, - padding: 4, - background: 'white', - cursor: 'grab', - textAlign: 'center', - }} - title={block.name} - > -
-
- {block.name} -
-
- ))} -
- {filteredBlocks.length === 0 && ( -

Keine Blöcke in dieser Kategorie.

- )} -
- ); -}; - -export default BlockLibrary; diff --git a/frontend/src/components/BlockLibrary.tsx b/frontend/src/components/BlockLibrary.tsx new file mode 100644 index 0000000..9784a25 --- /dev/null +++ b/frontend/src/components/BlockLibrary.tsx @@ -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 = ({ blocks, category, onCategoryChange, onSearch, onDragBlock, onRenameBlock, onDuplicateBlock, onDeleteBlock, onSvgImport, onSaveGroupAsBlock }) => { + const [searchQuery, setSearchQuery] = useState(''); + const [expandedCats, setExpandedCats] = useState>(new Set(['Bestuhlung'])); + const fileInputRef = useRef(null); + + const handleSearch = (e: React.ChangeEvent) => { + 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 = {}; + 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) => { + 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 ( + <> +
+ + + + +
+
+ {categories.map((cat) => ( + + ))} +
+
+ + {onSaveGroupAsBlock && ( + + )} + +
+
+ {Object.entries(grouped).map(([cat, catBlocks]) => ( +
+
toggleCat(cat)}> + {expandedCats.has(cat) ? '▾' : '▸'} + 📁 + {cat} + {catBlocks.length} +
+ {expandedCats.has(cat) && ( +
+ {catBlocks.map((block) => ( +
handleDragStart(e, block.id)} + title={block.description} + > + 📦 + {block.name} + e.stopPropagation()}> + {onRenameBlock && ( + + )} + {onDuplicateBlock && ( + + )} + {onDeleteBlock && ( + + )} + +
+ ))} +
+ )} +
+ ))} + {filteredBlocks.length === 0 && ( +
Keine Blöcke gefunden
+ )} +
+ + ); +}; + +export default BlockLibrary; diff --git a/frontend/src/components/CADCanvas.jsx b/frontend/src/components/CADCanvas.jsx deleted file mode 100644 index 905e435..0000000 --- a/frontend/src/components/CADCanvas.jsx +++ /dev/null @@ -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 ( -
- -
- Zoom: {Math.round(zoom * 100)}% -
-
- ); -}); - -export default CADCanvas; diff --git a/frontend/src/components/CanvasArea.css b/frontend/src/components/CanvasArea.css deleted file mode 100644 index 340986e..0000000 --- a/frontend/src/components/CanvasArea.css +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx new file mode 100644 index 0000000..26a24aa --- /dev/null +++ b/frontend/src/components/CanvasArea.tsx @@ -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 = ({ + 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(null); + const zoomPanRef = useRef(null); + const renderEngineRef = useRef(null); + const interactionRef = useRef(null); + const spatialIndexRef = useRef(null); + const layerManagerRef = useRef(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>([]); + 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 ( +
+
+ X{cursorPos.x.toFixed(3)} + Y{cursorPos.y.toFixed(3)} + m +
+ + { 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 }) => ( +
+
+
+ {cursor.userName} +
+
+ ))} + +
+ + 100% + + +
+ + + +
+ +
+
+ ); +}; + +export default CanvasArea; diff --git a/frontend/src/components/CommandLine.css b/frontend/src/components/CommandLine.css deleted file mode 100644 index eeb8cd3..0000000 --- a/frontend/src/components/CommandLine.css +++ /dev/null @@ -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)); - } -} \ No newline at end of file diff --git a/frontend/src/components/CommandLine.tsx b/frontend/src/components/CommandLine.tsx new file mode 100644 index 0000000..6eb19c0 --- /dev/null +++ b/frontend/src/components/CommandLine.tsx @@ -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 = { + draw: '#22c55e', + modify: '#f97316', + view: '#06b6d4', + meta: '#a855f7', + special: '#ec4899', +}; + +const CommandLine: React.FC = ({ history, onCommand }) => { + const [input, setInput] = useState(''); + const [selectedSuggestion, setSelectedSuggestion] = useState(0); + const [commandHistory, setCommandHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const [showSuggestions, setShowSuggestions] = useState(false); + const inputRef = useRef(null); + const historyRef = useRef(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) => { + 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 ( +
+
+ {entries.map((entry, i) => ( +
+ {entry.prefix} + {entry.text} +
+ ))} +
+
+ {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((cmd, i) => ( +
{ + e.preventDefault(); + handleSuggestionClick(cmd); + }} + onMouseEnter={() => setSelectedSuggestion(i)} + > + + {cmd.category} + + + {cmd.name} + + {cmd.aliases.length > 0 && ( + + ({cmd.aliases.join(', ')}) + + )} + + {cmd.description} + +
+ ))} +
+ )} + + +
+
+ ); +}; + +export default CommandLine; diff --git a/frontend/src/components/HistoryPanel.tsx b/frontend/src/components/HistoryPanel.tsx new file mode 100644 index 0000000..87ba5de --- /dev/null +++ b/frontend/src/components/HistoryPanel.tsx @@ -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 = ({ entries, onJumpTo, onClose }) => { + if (entries.length === 0) { + return ( +
+
+

Historie

+ +
+

Keine Historie vorhanden.

+
+ ); + } + + return ( +
+
+

Historie ({entries.length})

+ +
+
+ {entries.map((entry, idx) => ( + + ))} +
+
+ ); +}; + +export default HistoryPanel; diff --git a/frontend/src/components/KICopilot.tsx b/frontend/src/components/KICopilot.tsx new file mode 100644 index 0000000..5d11f95 --- /dev/null +++ b/frontend/src/components/KICopilot.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import type { KICopilotProps } from '../types/ui.types'; + +const defaultSuggestions = [ + { id: 's1', icon: , label: '5 Reihen à 22 Stühle anlegen' }, + { id: 's2', icon: , label: 'Optimale Bestuhlung vorschlagen' }, + { id: 's3', icon: , 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: Ich erstelle 110 Stühle 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! ● function_call: placeSeating(rows=5, cols=22, gap=1.0) }, +]; + +const KICopilot: React.FC = ({ 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 ( + <> +
+
+ +
+
+
KI Copilot
+
Powered by Claude
+
+ Online +
+ +
+
Schnell-Aktionen
+ {allSuggestions.map((s) => ( + + ))} +
+ +
+ {allMessages.map((msg) => ( +
+
{msg.content}
+
+ ))} + {loading && ( +
+
+ + + +
+
+ )} +
+ +
+ setInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSend(); }} + /> + + +
+ + ); +}; + +export default KICopilot; diff --git a/frontend/src/components/LayerPanel.jsx b/frontend/src/components/LayerPanel.jsx deleted file mode 100644 index 132518a..0000000 --- a/frontend/src/components/LayerPanel.jsx +++ /dev/null @@ -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 ( -
-
- setNewLayerName(e.target.value)} - style={{ flex: 1, padding: 4, borderRadius: 3, border: '1px solid #ccc' }} - /> - -
- {layers.map((layer, idx) => ( -
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, - }} - > - {layer.name} - - - - - -
- ))} -
- ); -}; - -export default LayerPanel; diff --git a/frontend/src/components/LayerPanel.tsx b/frontend/src/components/LayerPanel.tsx new file mode 100644 index 0000000..93c7e62 --- /dev/null +++ b/frontend/src/components/LayerPanel.tsx @@ -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 = { + 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 = ( + + + + + +); + +const elementIcons: Record = { + line: , + rect: , + circle: , + arc: , + polyline: , + polygon: , + text: , + dimension: , + leader: , + revcloud: , + hatch: , + chair: , + 'seating-row': , + 'seating-block': , + table: , + stage: , + 'seating-template': , +}; + +const defaultIcon = ; + +const LayerPanel: React.FC = ({ + 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 ( +
+
+ Ebenen + +
+
+ {tree.length === 0 ? ( +
+ Keine Ebenen vorhanden +
+ ) : ( + node.icon} + renderActions={(node) => { + const layer = layers.find((l) => l.id === node.id); + if (layer) { + return ( +
+ + + + + +
+ ); + } + const element = elements.find((el) => el.id === node.id); + if (element) { + const isVisible = element.properties?.visible !== false; + return ( +
+ + +
+ ); + } + return null; + }} + /> + )} +
+
+ ); +}; + +export default LayerPanel; diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx new file mode 100644 index 0000000..a0e9d9a --- /dev/null +++ b/frontend/src/components/LeftSidebar.tsx @@ -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: }, + { tool: 'pan', title: 'Pan (P / Leertaste)', label: 'Pan', kbd: 'P', svg: }, + { tool: 'zoom-win', title: 'Zoom-Fenster (Z)', label: 'Zoom', kbd: 'Z', svg: }, + { tool: 'measure', title: 'Messen', label: 'Messen', kbd: 'M', svg: }, +]; + +const sectionZeichnen: ToolDef[] = [ + { tool: 'line', title: 'Linie (L)', label: 'Linie', kbd: 'L', svg: }, + { tool: 'polyline', title: 'Polylinie (PL)', label: 'Polylinie', kbd: 'PL', svg: }, + { tool: 'rect', title: 'Rechteck (REC)', label: 'Rechteck', kbd: 'REC', svg: }, + { tool: 'circle', title: 'Kreis (C)', label: 'Kreis', kbd: 'C', svg: }, + { tool: 'arc', title: 'Bogen (A)', label: 'Bogen', kbd: 'A', svg: }, + { tool: 'text', title: 'Text (T)', label: 'Text', kbd: 'T', svg: }, + { tool: 'dimension', title: 'Bemaßung (DIM)', label: 'Bemaßung', kbd: 'DIM', svg: }, + { tool: 'hatch', title: 'Schraffur (H)', label: 'Schraffur', kbd: 'H', svg: }, + { tool: 'leader', title: 'Hinweislinie (LD)', label: 'Hinweis', kbd: 'LD', svg: }, + { tool: 'revcloud', title: 'Revisionswolke (REV)', label: 'RevCloud', kbd: 'REV', svg: }, +]; + +const sectionBearbeiten: ToolDef[] = [ + { tool: 'move', title: 'Verschieben (M)', label: 'Move', kbd: 'M', svg: }, + { tool: 'copy', title: 'Kopieren (CO)', label: 'Copy', kbd: 'CO', svg: }, + { tool: 'rotate', title: 'Rotieren (RO)', label: 'Rotate', kbd: 'RO', svg: }, + { tool: 'scale', title: 'Skalieren (SC)', label: 'Scale', kbd: 'SC', svg: }, + { tool: 'mirror', title: 'Spiegeln (MI)', label: 'Mirror', kbd: 'MI', svg: }, + { tool: 'trim', title: 'Trimmen (TR)', label: 'Trim', kbd: 'TR', svg: }, + { tool: 'offset', title: 'Versatz (O)', label: 'Offset', kbd: 'O', svg: }, + { tool: 'delete', title: 'Löschen (E)', label: 'Löschen', kbd: 'E', svg: }, +]; + +const sectionBestuhlung: ToolDef[] = [ + { tool: 'seating-row', title: 'Reihen-Bestuhlung', label: 'Reihe', kbd: 'R', svg: }, + { tool: 'seating-block', title: 'Block-Bestuhlung', label: 'Block', kbd: 'B', svg: }, + { tool: 'table', title: 'Tisch (TAB)', label: 'Tisch', kbd: 'TAB', svg: }, + { tool: 'stage', title: 'Bühne', label: 'Bühne', kbd: 'S', svg: }, + { tool: 'seating-template', title: 'Vorlagen', label: 'Vorlagen', kbd: 'TPL', 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 = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect, onCollapse }) => { + return ( + + ); +}; + +export default LeftSidebar; diff --git a/frontend/src/components/MobileDrawers.tsx b/frontend/src/components/MobileDrawers.tsx new file mode 100644 index 0000000..cbb86d0 --- /dev/null +++ b/frontend/src/components/MobileDrawers.tsx @@ -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: }, + { id: 'layer', label: 'Layer', svg: }, + { id: 'library', label: 'Lib', svg: }, + { id: 'ki', label: 'KI', svg: }, +]; + +const MobileDrawers: React.FC = ({ + leftOpen, rightOpen, activeRightTab, onCloseLeft, onCloseRight, onRightTabChange, +}) => { + return ( + <> + + + + + + + ); +}; + +export default MobileDrawers; diff --git a/frontend/src/components/PluginManager.tsx b/frontend/src/components/PluginManager.tsx new file mode 100644 index 0000000..ef5f07e --- /dev/null +++ b/frontend/src/components/PluginManager.tsx @@ -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 = { + tools: 'Werkzeuge', + elements: 'Elemente', + 'import-export': 'Import/Export', + theme: 'Theme', + other: 'Sonstige', +}; + +const categoryIcons: Record = { + tools: , + elements: , + 'import-export': , + theme: , + other: , +}; + +const PluginManager: React.FC = () => { + const [states, setStates] = useState([]); + const [expandedId, setExpandedId] = useState(null); + + useEffect(() => { + const update = () => setStates(pluginRegistry.getStates()); + update(); + return pluginRegistry.subscribe(update); + }, []); + + const handleToggle = (id: string) => { + pluginRegistry.toggle(id); + }; + + if (states.length === 0) { + return ( +
+ Keine Plugins installiert. +
+ ); + } + + return ( +
+
+ Plugins + {states.filter(s => s.enabled).length} aktiv · {states.length} gesamt +
+ {states.map((state) => ( +
+
setExpandedId(expandedId === state.manifest.id ? null : state.manifest.id)}> +
+ {categoryIcons[state.manifest.category] || categoryIcons.other} +
+
+
{state.manifest.name}
+
v{state.manifest.version} · {categoryLabels[state.manifest.category] || state.manifest.category}
+
+ +
+ {expandedId === state.manifest.id && ( +
+

{state.manifest.description}

+
Autor: {state.manifest.author}
+
+ )} +
+ ))} +
+ ); +}; + +export default PluginManager; diff --git a/frontend/src/components/PluginRegistry.jsx b/frontend/src/components/PluginRegistry.jsx deleted file mode 100644 index 8ccd31f..0000000 --- a/frontend/src/components/PluginRegistry.jsx +++ /dev/null @@ -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; diff --git a/frontend/src/components/PrintPreview/PrintPreview.css b/frontend/src/components/PrintPreview/PrintPreview.css deleted file mode 100644 index 464abfd..0000000 --- a/frontend/src/components/PrintPreview/PrintPreview.css +++ /dev/null @@ -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; - } -} diff --git a/frontend/src/components/PropertiesPanel.tsx b/frontend/src/components/PropertiesPanel.tsx new file mode 100644 index 0000000..0d5a828 --- /dev/null +++ b/frontend/src/components/PropertiesPanel.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import type { PropertiesPanelProps } from '../types/ui.types'; + +const PropertiesPanel: React.FC = ({ 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 ( + <> +
+
+ Auswahl · 1 Stuhl + +
+
+ +
+
Geometrie
+
+ Position X + onUpdateProperty('x', parseFloat(e.target.value) || 0)} /> +
+
+ Position Y + onUpdateProperty('y', parseFloat(e.target.value) || 0)} /> +
+
+ Breite + onUpdateProperty('width', parseFloat(e.target.value) || 0)} /> +
+
+ Tiefe + onUpdateProperty('height', parseFloat(e.target.value) || 0)} /> +
+
+ Drehung + onUpdateProperty('rotation', parseFloat(e.target.value) || 0)} /> +
+
+ +
+
Darstellung
+
+ Farbe +
+ + onUpdateProperty('stroke', e.target.value)} /> + onUpdateProperty('stroke', e.target.value)} /> +
+
+
+ Linientyp + +
+
+ Stärke + +
+
+ Layer + +
+
+ +
+
Block
+
+ Block-Name + onUpdateProperty('blockId', e.target.value)} /> +
+
+ Beschreibung + onUpdateProperty('text', e.target.value)} /> +
+
+ + ); +}; + +export default PropertiesPanel; diff --git a/frontend/src/components/PropertiesPanel/PropertiesPanel.css b/frontend/src/components/PropertiesPanel/PropertiesPanel.css deleted file mode 100644 index 40bf2e7..0000000 --- a/frontend/src/components/PropertiesPanel/PropertiesPanel.css +++ /dev/null @@ -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; - } -} diff --git a/frontend/src/components/RibbonBar.css b/frontend/src/components/RibbonBar.css deleted file mode 100644 index 3b0e223..0000000 --- a/frontend/src/components/RibbonBar.css +++ /dev/null @@ -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; - } -} diff --git a/frontend/src/components/RibbonBar.tsx b/frontend/src/components/RibbonBar.tsx new file mode 100644 index 0000000..2602b52 --- /dev/null +++ b/frontend/src/components/RibbonBar.tsx @@ -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: }, + { id: 'insert', label: 'Einfügen', svg: }, + { id: 'format', label: 'Format', svg: }, + { id: 'view', label: 'Ansicht', svg: }, + { id: 'tools', label: 'Extras', svg: }, + { id: 'ki', label: 'KI', svg: }, +]; + +const RibbonBar: React.FC = ({ activeTab, onTabChange, onAction }) => { + return ( + + ); +}; + +export default RibbonBar; diff --git a/frontend/src/components/RightSidebar.tsx b/frontend/src/components/RightSidebar.tsx new file mode 100644 index 0000000..9126604 --- /dev/null +++ b/frontend/src/components/RightSidebar.tsx @@ -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: }, + { id: 'layer', label: 'Layer', svg: }, + { id: 'library', label: 'Bibliothek', svg: }, + { id: 'ki', label: 'KI', svg: , badge: 2 }, +]; + +const RightSidebar: React.FC = ({ + 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 ( + + ); +}; + +export default RightSidebar; diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx new file mode 100644 index 0000000..b641a2f --- /dev/null +++ b/frontend/src/components/SettingsModal.tsx @@ -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 = ({ open, onClose }) => { + const { user } = useAuth(); + const [activeTab, setActiveTab] = useState('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 ( +
+ + setDisplayName(e.target.value)} /> + + + +
{initials}
+
+ ); + case 'password': + return ( +
+ + setOldPassword(e.target.value)} /> + + setNewPassword(e.target.value)} /> + + setConfirmPassword(e.target.value)} /> + {pwMessage &&
{pwMessage}
} + +
+ ); + case 'language': + return ( +
+ + +
+ ); + case 'theme': + return ( +
+ +
+ + +
+ +
+ setAccentColor(e.target.value)} /> + {accentColor} +
+
+ ); + case 'users': + return ( +
+
+ Benutzerverwaltung + +
+
+
+
LM
+
+
Leopold M.
+
admin@example.com
+
+ Admin + +
+
+
+ ); + case 'plugins': + return ( +
+ +
+ ); + case 'ai': + return ( +
+ + + + + + setAiApiKey(e.target.value)} placeholder="••••••••••••" /> +
+ ); + default: + return null; + } + }; + + return ( +
+
e.stopPropagation()}> + +
+ {visibleTabs.map((tab) => ( + + ))} +
+
+ {renderTabContent()} +
+
+
+ ); +}; + +export default SettingsModal; diff --git a/frontend/src/components/SidePanel.css b/frontend/src/components/SidePanel.css deleted file mode 100644 index be72f6f..0000000 --- a/frontend/src/components/SidePanel.css +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/frontend/src/components/StatusBar.css b/frontend/src/components/StatusBar.css deleted file mode 100644 index 4982d98..0000000 --- a/frontend/src/components/StatusBar.css +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx new file mode 100644 index 0000000..474a756 --- /dev/null +++ b/frontend/src/components/StatusBar.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type { StatusBarProps } from '../types/ui.types'; + +const StatusBar: React.FC = ({ + snapEnabled, orthoEnabled, polarEnabled, gridEnabled, + cursorX, cursorY, activeLayer, activeTool, onlineCount, + onToggleSnap, onToggleOrtho, onTogglePolar, onToggleGrid, seatCount, +}) => { + return ( +
+
+ + SNAP +
+
+ + ORTHO +
+
+ + POLAR +
+
+ + GRID +
+
+ X: {cursorX.toFixed(3)} m +
+
+ Y: {cursorY.toFixed(3)} m +
+
+ + {activeLayer} +
+
+ + {activeTool} +
+ {seatCount !== undefined && seatCount > 0 && ( +
+ + {seatCount} Stühle +
+ )} +
+ + Online · {onlineCount} weiterer +
+
+ ); +}; + +export default StatusBar; diff --git a/frontend/src/components/Toolbar.jsx b/frontend/src/components/Toolbar.jsx deleted file mode 100644 index d98c52f..0000000 --- a/frontend/src/components/Toolbar.jsx +++ /dev/null @@ -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 => ( - - ))} - - ); -}; - -export default Toolbar; diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx new file mode 100644 index 0000000..1df92a7 --- /dev/null +++ b/frontend/src/components/Topbar.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import type { TopbarProps } from '../types/ui.types'; + +const Topbar: React.FC = ({ projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme, onOpenSettings }) => { + return ( +
+
+ + + web-cad + | + + {savedStatus} +
+
+ + + + + + + + + + + + +
LM
+
+
+ ); +}; + +export default Topbar; diff --git a/frontend/src/components/TreeView.tsx b/frontend/src/components/TreeView.tsx new file mode 100644 index 0000000..4176364 --- /dev/null +++ b/frontend/src/components/TreeView.tsx @@ -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 = ({ + nodes, + selectedId, + onSelect, + onToggle, + onReorder, + renderIcon, + renderActions, + renderDetail, + draggable = false, +}) => { + const [expandedSet, setExpandedSet] = useState>(new Set()); + const [draggedId, setDraggedId] = useState(null); + const [dragOverId, setDragOverId] = useState(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 ( + +
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 && ( + + )} + {!hasChildren && } + {renderIcon && {renderIcon(node)}} + {node.name} + {node.count !== undefined && ({node.count})} + {renderActions && e.stopPropagation()}>{renderActions(node)}} +
+ {renderDetail && isSelected && ( +
+ {renderDetail(node)} +
+ )} + {hasChildren && isExpanded && ( +
+ {node.children!.map((child) => renderNode(child, level + 1))} +
+ )} +
+ ); + }; + + return
{nodes.map((node) => renderNode(node, 0))}
; +}; + +export default TreeView; diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx deleted file mode 100644 index 4b07234..0000000 --- a/frontend/src/contexts/AuthContext.jsx +++ /dev/null @@ -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 ( - - {children} - - ); -}; - -export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..9b87296 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -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; + register: (email: string, password: string, name: string) => Promise; + logout: () => void; + clearError: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(() => localStorage.getItem('auth_token')); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/frontend/src/crdt/AwarenessManager.ts b/frontend/src/crdt/AwarenessManager.ts new file mode 100644 index 0000000..021dcd2 --- /dev/null +++ b/frontend/src/crdt/AwarenessManager.ts @@ -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; + selections: Y.Map; +} + +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('awareness-cursors'), + selections: this.yjsDoc.doc.getMap('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(); + } + } +} diff --git a/frontend/src/crdt/WebSocketProvider.ts b/frontend/src/crdt/WebSocketProvider.ts new file mode 100644 index 0000000..82888af --- /dev/null +++ b/frontend/src/crdt/WebSocketProvider.ts @@ -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 | 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); + } + } +} diff --git a/frontend/src/crdt/YjsDocument.ts b/frontend/src/crdt/YjsDocument.ts new file mode 100644 index 0000000..85f90b0 --- /dev/null +++ b/frontend/src/crdt/YjsDocument.ts @@ -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; + layers: Y.Map; + blocks: Y.Map; + meta: Y.Map; +} + +export class YjsDocument { + readonly doc: Y.Doc; + readonly data: YjsDocumentData; + + constructor() { + this.doc = new Y.Doc(); + this.data = { + elements: this.doc.getMap('elements'), + layers: this.doc.getMap('layers'), + blocks: this.doc.getMap('blocks'), + meta: this.doc.getMap('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(); + } +} diff --git a/frontend/src/crdt/index.ts b/frontend/src/crdt/index.ts new file mode 100644 index 0000000..8c77da0 --- /dev/null +++ b/frontend/src/crdt/index.ts @@ -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'; diff --git a/frontend/src/crdt/useYjsBinding.ts b/frontend/src/crdt/useYjsBinding.ts new file mode 100644 index 0000000..705963c --- /dev/null +++ b/frontend/src/crdt/useYjsBinding.ts @@ -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(null); + const providerRef = useRef(null); + const awarenessRef = useRef(null); + const unbindRef = useRef<(() => void) | null>(null); + + const [status, setStatus] = useState('disconnected'); + const [elements, setElements] = useState([]); + const [layers, setLayers] = useState([]); + const [blocks, setBlocks] = useState([]); + const [cursors, setCursors] = useState([]); + const [selections, setSelections] = useState([]); + + // 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, + }; +} diff --git a/frontend/src/history/HistoryManager.ts b/frontend/src/history/HistoryManager.ts new file mode 100644 index 0000000..2dcaf0d --- /dev/null +++ b/frontend/src/history/HistoryManager.ts @@ -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): 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, 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; +} diff --git a/frontend/src/history/index.ts b/frontend/src/history/index.ts new file mode 100644 index 0000000..d732b63 --- /dev/null +++ b/frontend/src/history/index.ts @@ -0,0 +1,2 @@ +export { HistoryManager, getDefaultHistoryManager } from './HistoryManager'; +export type { CADStateSnapshot, HistoryEntry, HistoryManagerOptions } from './HistoryManager'; diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index 122aedf..0000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,8 +0,0 @@ -* { - box-sizing: border-box; -} -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #f0f2f5; -} diff --git a/frontend/src/interaction/index.ts b/frontend/src/interaction/index.ts new file mode 100644 index 0000000..54f1a72 --- /dev/null +++ b/frontend/src/interaction/index.ts @@ -0,0 +1,1036 @@ +import type { CADElement, ElementType, ToolType } from '../types/cad.types'; +import { ZoomPanController } from '../canvas/ZoomPanController'; +import { RenderEngine } from '../canvas/RenderEngine'; +import { SnapEngine } from '../canvas/SnapEngine'; +import { SelectionEngine } from '../canvas/SelectionEngine'; +import { SpatialIndex } from '../canvas/SpatialIndex'; +import { LayerManager } from '../canvas/LayerManager'; +import { moveElement, rotateElement, scaleElement, mirrorElement, offsetElement, trimElement, extendElement, filletElements, distance, angleBetween } from '../tools/modification/geometry'; +import { SeatingService, SEATING_TEMPLATES } from '../services/seatingService'; +import { DimensionService } from '../services/dimensionService'; + +export type ToolPhase = 'idle' | 'drawing' | 'modifying' | 'panning' | 'selecting'; + +export interface ToolState { + activeTool: ToolType; + phase: ToolPhase; + points: Array<{ x: number; y: number }>; // accumulated world points for current operation + previewElement: CADElement | null; // temporary element being drawn + ortho: boolean; + snapEnabled: boolean; +} + +export interface InteractionConfig { + orthoKey: string; // F8 + snapToggleKey: string; // F3 + escapeKey: string; + deleteKey: string; + selectAllKey: string; + undoKey: string; + redoKey: string; +} + +export class InteractionEngine { + private canvas: HTMLCanvasElement; + private zoomPan: ZoomPanController; + private renderEngine: RenderEngine; + private snapEngine: SnapEngine; + private selectionEngine: SelectionEngine; + private spatialIndex: SpatialIndex; + private layerManager: LayerManager; + private state: ToolState; + private config: InteractionConfig; + private selectedTemplate: string | null = null; + private allElements: CADElement[] = []; + private isMouseDown = false; + private isRightDown = false; + private lastMouseScreen = { x: 0, y: 0 }; + private eventListeners: Array<{ type: string; fn: EventListener }> = []; + private onElementCreated?: (el: CADElement) => void; + private onElementsDeleted?: (ids: string[]) => void; + private onElementsModified?: (els: CADElement[]) => void; + private onToolStateChanged?: (state: ToolState) => void; + private onCursorMoved?: (worldX: number, worldY: number) => void; + private onCommandTrigger?: (cmd: string) => void; + private onTextEdit?: (el: CADElement) => void; + private onSelectionChange?: (selectedIds: string[]) => void; + private animationFrame: number | null = null; + + constructor( + canvas: HTMLCanvasElement, + zoomPan: ZoomPanController, + renderEngine: RenderEngine, + snapEngine: SnapEngine, + selectionEngine: SelectionEngine, + spatialIndex: SpatialIndex, + layerManager: LayerManager, + ) { + this.canvas = canvas; + this.zoomPan = zoomPan; + this.renderEngine = renderEngine; + this.snapEngine = snapEngine; + this.selectionEngine = selectionEngine; + this.spatialIndex = spatialIndex; + this.layerManager = layerManager; + this.state = { + activeTool: 'select', + phase: 'idle', + points: [], + previewElement: null, + ortho: false, + snapEnabled: true, + }; + this.config = { + orthoKey: 'F8', + snapToggleKey: 'F3', + escapeKey: 'Escape', + deleteKey: 'Delete', + selectAllKey: 'a', + undoKey: 'z', + redoKey: 'y', + }; + } + + setElements(elements: CADElement[]): void { + this.allElements = elements; + this.snapEngine.setElements(elements); + } + + setSnapEnabled(enabled: boolean): void { + this.state.snapEnabled = enabled; + } + + setOrthoEnabled(enabled: boolean): void { + this.state.ortho = enabled; + } + + setPolarEnabled(enabled: boolean): void { + this.snapEngine.setConfig({ polarEnabled: enabled }); + } + + setSelectedTemplate(templateName: string | null): void { + this.selectedTemplate = templateName; + } + + setTool(tool: ToolType): void { + this.state.activeTool = tool; + this.state.phase = 'idle'; + this.state.points = []; + this.state.previewElement = null; + if (tool !== 'select' && tool !== 'pan') { + this.selectionEngine.clearSelection(); + this.emitSelectionChange(); + } + this.notifyToolStateChanged(); + this.requestRender(); + } + + getToolState(): ToolState { + return { ...this.state, points: [...this.state.points] }; + } + + setCallbacks(callbacks: { + onElementCreated?: (el: CADElement) => void; + onElementsDeleted?: (ids: string[]) => void; + onElementsModified?: (els: CADElement[]) => void; + onToolStateChanged?: (state: ToolState) => void; + onCursorMoved?: (worldX: number, worldY: number) => void; + onCommandTrigger?: (cmd: string) => void; + onTextEdit?: (el: CADElement) => void; + onSelectionChange?: (selectedIds: string[]) => void; + }): void { + this.onElementCreated = callbacks.onElementCreated; + this.onElementsDeleted = callbacks.onElementsDeleted; + this.onElementsModified = callbacks.onElementsModified; + this.onToolStateChanged = callbacks.onToolStateChanged; + this.onCursorMoved = callbacks.onCursorMoved; + this.onCommandTrigger = callbacks.onCommandTrigger; + this.onTextEdit = callbacks.onTextEdit; + this.onSelectionChange = callbacks.onSelectionChange; + } + + private emitSelectionChange(): void { + if (this.onSelectionChange) { + this.onSelectionChange(Array.from(this.selectionEngine.getSelectedIds())); + } + } + + attach(): void { + this.addListener('mousedown', this.onMouseDown.bind(this) as EventListener); + this.addListener('mousemove', this.onMouseMove.bind(this) as EventListener); + this.addListener('mouseup', this.onMouseUp.bind(this) as EventListener); + this.addListener('dblclick', this.onDoubleClick.bind(this) as EventListener); + this.addListener('wheel', this.onWheel.bind(this) as EventListener, { passive: false } as AddEventListenerOptions); + this.addListener('contextmenu', this.onContextMenu.bind(this) as EventListener); + document.addEventListener('keydown', this.onKeyDown.bind(this) as EventListener); + } + + detach(): void { + for (const { type, fn } of this.eventListeners) { + this.canvas.removeEventListener(type, fn); + } + this.eventListeners = []; + document.removeEventListener('keydown', this.onKeyDown.bind(this) as EventListener); + if (this.animationFrame !== null) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + } + + private addListener(type: string, fn: EventListener, options?: AddEventListenerOptions): void { + this.canvas.addEventListener(type, fn, options); + this.eventListeners.push({ type, fn }); + } + + private getWorldCoords(e: MouseEvent): { x: number; y: number } { + return this.zoomPan.screenToWorld(e.clientX, e.clientY); + } + + private applySnap(x: number, y: number): { x: number; y: number } { + if (!this.state.snapEnabled) return { x, y }; + // Pass last point as reference for polar tracking + const refPoint = this.state.points.length > 0 ? this.state.points[this.state.points.length - 1] : undefined; + const result = this.snapEngine.snap(x, y, refPoint); + if (result.point) { + this.renderEngine.setSnapPoints(result.preview); + this.renderEngine.setActiveSnapPoint(result.point); + return { x: result.point.x, y: result.point.y }; + } + this.renderEngine.setSnapPoints([]); + this.renderEngine.setActiveSnapPoint(null); + return { x, y }; + } + + private applyOrtho(x: number, y: number): { x: number; y: number } { + if (!this.state.ortho || this.state.points.length === 0) return { x, y }; + const last = this.state.points[this.state.points.length - 1]; + const dx = x - last.x; + const dy = y - last.y; + if (Math.abs(dx) > Math.abs(dy)) { + return { x, y: last.y }; + } else { + return { x: last.x, y }; + } + } + + private onMouseDown(e: MouseEvent): void { + e.preventDefault(); + this.isMouseDown = true; + this.lastMouseScreen = { x: e.clientX, y: e.clientY }; + + if (e.button === 2) { + this.isRightDown = true; + this.state.phase = 'panning'; + return; + } + + if (e.button === 1 || this.state.activeTool === 'pan') { + this.state.phase = 'panning'; + return; + } + + const raw = this.getWorldCoords(e); + const snapped = this.applySnap(raw.x, raw.y); + const final = this.applyOrtho(snapped.x, snapped.y); + + switch (this.state.activeTool) { + case 'select': + this.handleSelectDown(e, raw); + break; + case 'line': + case 'polyline': + case 'rect': + case 'circle': + case 'arc': + case 'polygon': + case 'text': + case 'dimension': + case 'leader': + this.handleDrawDown(final); + break; + case 'revcloud': + this.handleDrawDown(final); + break; + case 'chair': + case 'seating-row': + case 'seating-block': + case 'table': + case 'stage': + case 'seating-template': + this.handlePlaceDown(final); + break; + case 'hatch': + this.handleHatchDown(final); + break; + case 'zoom-win': + this.handleZoomWinDown(final); + break; + case 'measure': + this.handleMeasureDown(final); + break; + case 'move': + case 'copy': + case 'rotate': + case 'scale': + case 'mirror': + case 'trim': + case 'extend': + case 'fillet': + case 'offset': + this.handleModifyDown(final); + break; + case 'delete': + this.handleDeleteDown(raw); + break; + default: + break; + } + this.requestRender(); + } + + private onMouseMove(e: MouseEvent): void { + this.lastMouseScreen = { x: e.clientX, y: e.clientY }; + const raw = this.getWorldCoords(e); + this.onCursorMoved?.(raw.x, raw.y); + + if (this.state.phase === 'panning') { + const dx = e.movementX; + const dy = e.movementY; + this.zoomPan.pan(dx, dy); + this.requestRender(); + return; + } + + const snapped = this.applySnap(raw.x, raw.y); + const final = this.applyOrtho(snapped.x, snapped.y); + + // Hover detection for select tool + if (this.state.activeTool === 'select' && this.state.phase === 'idle') { + const hit = this.renderEngine.hitTest(raw.x, raw.y, 5); + this.selectionEngine.setHover(hit?.id ?? null); + } + + // Update preview during drawing + if (this.state.phase === 'drawing' && this.state.points.length > 0) { + this.updatePreview(final); + } + + // Update preview during modification + if (this.state.phase === 'modifying' && this.state.points.length > 0) { + this.updateModifyPreview(final); + } + + // Update box selection + if (this.selectionEngine.isBoxSelecting()) { + this.selectionEngine.updateBoxSelect(raw.x, raw.y, this.allElements); + } + + this.requestRender(); + } + + private onMouseUp(e: MouseEvent): void { + e.preventDefault(); + this.isMouseDown = false; + + if (e.button === 2 || (e.button === 1 && this.state.phase === 'panning')) { + this.isRightDown = false; + this.state.phase = this.state.activeTool === 'pan' ? 'idle' : 'idle'; + this.requestRender(); + return; + } + + if (this.state.phase === 'panning') { + this.state.phase = 'idle'; + this.requestRender(); + return; + } + + if (this.state.activeTool === 'select' && this.selectionEngine.isBoxSelecting()) { + this.selectionEngine.finishBoxSelect(this.allElements); + this.emitSelectionChange(); + this.requestRender(); + return; + } + + // Click-drag: confirm draw on mouseup for 2-point tools + if (this.state.phase === 'drawing' && this.state.points.length >= 1) { + const raw = this.getWorldCoords(e); + const snapped = this.applySnap(raw.x, raw.y); + const final = this.applyOrtho(snapped.x, snapped.y); + const tool = this.state.activeTool; + // For 2-point tools: use mouseup position as second point + if (tool === 'line' || tool === 'rect' || tool === 'circle' || tool === 'dimension' || tool === 'leader') { + this.state.points.push(final); + this.confirmDraw(); + return; + } + } + } + + private onWheel(e: WheelEvent): void { + e.preventDefault(); + const factor = e.deltaY > 0 ? 0.9 : 1.1; + const rect = this.canvas.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + this.zoomPan.zoomAt(cx, cy, factor); + this.requestRender(); + } + + private onContextMenu(e: MouseEvent): void { + e.preventDefault(); + } + + private onDoubleClick(e: MouseEvent): void { + e.preventDefault(); + if ( + (this.state.activeTool === 'polyline' || this.state.activeTool === 'polygon' || this.state.activeTool === 'revcloud') && + this.state.phase === 'drawing' && + this.state.points.length >= 2 + ) { + this.confirmDraw(); + } + } + + private onKeyDown(e: KeyboardEvent): void { + // Tool-independent keys + if (e.key === this.config.escapeKey) { + this.cancelCurrentOperation(); + return; + } + if (e.key === this.config.orthoKey) { + this.state.ortho = !this.state.ortho; + this.renderEngine.setOptions({ showOrtho: this.state.ortho }); + this.notifyToolStateChanged(); + this.requestRender(); + return; + } + if (e.key === this.config.snapToggleKey) { + this.state.snapEnabled = !this.state.snapEnabled; + this.renderEngine.setOptions({ showSnapPoints: this.state.snapEnabled }); + this.notifyToolStateChanged(); + this.requestRender(); + return; + } + if (e.key === this.config.deleteKey && this.state.activeTool === 'select') { + const ids = Array.from(this.selectionEngine.getSelectedIds()); + if (ids.length > 0) { + this.onElementsDeleted?.(ids); + } + return; + } + + // Ctrl+A = select all + if ((e.ctrlKey || e.metaKey) && e.key === this.config.selectAllKey) { + e.preventDefault(); + this.selectionEngine.selectAll(this.allElements); + this.emitSelectionChange(); + this.requestRender(); + return; + } + + // Ctrl+Z = undo (delegated to app) + if ((e.ctrlKey || e.metaKey) && e.key === this.config.undoKey) { + e.preventDefault(); + this.onCommandTrigger?.('undo'); + return; + } + + // Ctrl+Y = redo + if ((e.ctrlKey || e.metaKey) && e.key === this.config.redoKey) { + e.preventDefault(); + this.onCommandTrigger?.('redo'); + return; + } + + // Enter = confirm current operation + if (e.key === 'Enter' && this.state.phase === 'drawing') { + this.confirmDraw(); + return; + } + + // Enter = confirm modification with current cursor position + if (e.key === 'Enter' && this.state.phase === 'modifying') { + const world = this.zoomPan.screenToWorld(this.lastMouseScreen.x, this.lastMouseScreen.y); + this.confirmModify(world); + return; + } + + // Space = pan toggle (hold) + if (e.key === ' ' && this.state.activeTool !== 'pan') { + e.preventDefault(); + // Temporary pan — could implement hold-to-pan + return; + } + } + + // --- Tool handlers --- + + private handleSelectDown(e: MouseEvent, world: { x: number; y: number }): void { + const additive = e.shiftKey; + const subtractive = e.ctrlKey || e.metaKey; + this.selectionEngine.setOptions({ additive, subtractive }); + + if (!additive && !subtractive) { + // Start potential box select + this.selectionEngine.startBoxSelect(world.x, world.y); + } else { + this.selectionEngine.clickSelect(world.x, world.y, this.allElements); + this.emitSelectionChange(); + } + } + + private handleDrawDown(pt: { x: number; y: number }): void { + // Clear any previous points when starting a new draw operation + if (this.state.points.length === 0) { + this.state.phase = 'drawing'; + } + this.state.points.push(pt); + + // Arc: auto-confirm after 3 points (center, start, end) + if (this.state.activeTool === 'arc' && this.state.points.length >= 3) { + this.confirmDraw(); + return; + } + + // Text: single click placement, then prompt for text content + if (this.state.activeTool === 'text') { + this.confirmDraw(); + return; + } + + // Line, rect, circle, dimension, leader: wait for mouseup (click-drag behavior) + // Points are collected on mousedown, confirmed on mouseup + + this.updatePreview(pt); + this.notifyToolStateChanged(); + } + + private handlePlaceDown(pt: { x: number; y: number }): void { + const tool = this.state.activeTool; + const layerId = this.layerManager.getActiveLayerId(); + const seating = new SeatingService(); + + switch (tool) { + case 'chair': { + const el = seating.createChair(pt.x, pt.y, layerId); + this.onElementCreated?.(el); + break; + } + case 'seating-row': { + const els = seating.createSeatingRow(pt.x, pt.y, layerId); + for (const el of els) this.onElementCreated?.(el); + this.onCommandTrigger?.(`Reihe mit ${els.length} Stühlen erstellt`); + break; + } + case 'seating-block': { + const els = seating.createSeatingBlock(pt.x, pt.y, layerId); + for (const el of els) this.onElementCreated?.(el); + this.onCommandTrigger?.(`Block mit ${els.length} Stühlen erstellt`); + break; + } + case 'table': { + const el = seating.createTable(pt.x, pt.y, layerId); + this.onElementCreated?.(el); + break; + } + case 'stage': { + const el = seating.createStage(pt.x, pt.y, layerId); + this.onElementCreated?.(el); + break; + } + case 'seating-template': { + if (this.selectedTemplate) { + const els = seating.createFromTemplate(this.selectedTemplate, pt.x, pt.y, layerId); + for (const el of els) this.onElementCreated?.(el); + this.onCommandTrigger?.(`Vorlage "${this.selectedTemplate}" platziert: ${els.length} Elemente`); + } else { + this.onCommandTrigger?.('Keine Vorlage ausgewählt – bitte in der Seitenleiste wählen'); + } + break; + } + default: + break; + } + this.requestRender(); + } + + private handleHatchDown(pt: { x: number; y: number }): void { + // Hatch: select a closed boundary element, then apply hatch pattern + const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); + if (hit && (hit.type === 'rect' || hit.type === 'circle' || hit.type === 'polygon')) { + const updated: CADElement = { + ...hit, + properties: { ...hit.properties, hatch: true, hatchPattern: 'lines', hatchSpacing: 8 }, + }; + this.onElementsModified?.([updated]); + } + this.requestRender(); + } + + private handleZoomWinDown(pt: { x: number; y: number }): void { + // Zoom window: two clicks define the zoom area + if (this.state.points.length === 0) { + this.state.points.push(pt); + this.state.phase = 'drawing'; + this.onCommandTrigger?.('zoom: click opposite corner'); + } else { + const first = this.state.points[0]; + const minX = Math.min(first.x, pt.x); + const minY = Math.min(first.y, pt.y); + const maxX = Math.max(first.x, pt.x); + const maxY = Math.max(first.y, pt.y); + this.zoomPan.zoomToRect({ minX, minY, maxX, maxY }); + this.state.points = []; + this.state.phase = 'idle'; + this.requestRender(); + } + } + + private handleMeasureDown(pt: { x: number; y: number }): void { + // Measure: two clicks, display distance + if (this.state.points.length === 0) { + this.state.points.push(pt); + this.state.phase = 'drawing'; + this.onCommandTrigger?.('measure: click end point'); + } else { + const first = this.state.points[0]; + const dist = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); + const angle = (Math.atan2(pt.y - first.y, pt.x - first.x) * 180) / Math.PI; + this.onCommandTrigger?.(`distance: ${dist.toFixed(2)} angle: ${angle.toFixed(1)}°`); + this.state.points = []; + this.state.phase = 'idle'; + this.requestRender(); + } + } + + private modifySelected: CADElement[] = []; + private modifyBoundary: CADElement | null = null; + private modifyFilletRadius = 10; + + private handleModifyDown(pt: { x: number; y: number }): void { + const tool = this.state.activeTool; + + // Trim/Extend/Fillet: first click selects boundary element, not from selection + if (tool === 'trim' || tool === 'extend') { + if (this.state.points.length === 0) { + // First click: select boundary element + const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); + if (hit) { + this.modifyBoundary = hit; + this.state.phase = 'modifying'; + this.state.points.push(pt); + this.onCommandTrigger?.(`${tool}: select element to ${tool === 'trim' ? 'trim' : 'extend'}`); + } + return; + } + // Second click: select element to trim/extend + const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); + if (hit && this.modifyBoundary) { + const fn = tool === 'trim' ? trimElement : extendElement; + const result = fn(hit, this.modifyBoundary); + if (result) { + this.onElementsModified?.([result]); + } + this.resetModify(); + } + return; + } + + if (tool === 'fillet') { + if (this.state.points.length === 0) { + // First click: select first element + const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); + if (hit) { + this.modifySelected = [hit]; + this.state.phase = 'modifying'; + this.state.points.push(pt); + this.onCommandTrigger?.('fillet: select second element'); + } + return; + } + if (this.state.points.length === 1) { + // Second click: select second element + const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); + if (hit && this.modifySelected.length === 1) { + const result = filletElements(this.modifySelected[0], hit, this.modifyFilletRadius); + if (result) { + this.onElementsModified?.(result); + } + this.resetModify(); + } + return; + } + return; + } + + if (tool === 'offset') { + if (this.state.points.length === 0) { + // First click: select element to offset + const hit = this.renderEngine.hitTest(pt.x, pt.y, 5); + if (hit) { + this.modifySelected = [hit]; + this.state.phase = 'modifying'; + this.state.points.push(pt); + this.onCommandTrigger?.('offset: click to set direction and distance'); + } + return; + } + // Second click: direction + distance + if (this.modifySelected.length === 1) { + const first = this.state.points[0]; + const dist = distance(first, pt); + const offsetted = offsetElement(this.modifySelected[0], dist); + // Create as new element (copy) + this.onElementCreated?.({ ...offsetted, id: this.generateId() }); + this.resetModify(); + } + return; + } + + // Move/Copy/Rotate/Scale/Mirror: require existing selection + // Second click confirms the modification + if (this.state.phase === 'modifying' && this.state.points.length >= 1) { + this.confirmModify(pt); + return; + } + + const selected = this.selectionEngine.getSelectedElements(this.allElements); + if (selected.length === 0) { + this.onCommandTrigger?.('select-first'); + return; + } + + this.modifySelected = selected; + this.state.phase = 'modifying'; + this.state.points.push(pt); + this.notifyToolStateChanged(); + } + + private updateModifyPreview(pt: { x: number; y: number }): void { + if (this.modifySelected.length === 0 || this.state.points.length === 0) return; + const tool = this.state.activeTool; + const base = this.state.points[0]; + + if (tool === 'move' || tool === 'copy') { + const dx = pt.x - base.x; + const dy = pt.y - base.y; + // Show preview of first selected element moved + this.state.previewElement = moveElement(this.modifySelected[0], dx, dy); + } else if (tool === 'rotate') { + const angle = angleBetween(base, pt); + this.state.previewElement = rotateElement(this.modifySelected[0], base.x, base.y, angle); + } else if (tool === 'scale') { + const dist = distance(base, pt); + const refDist = distance(base, { x: this.modifySelected[0].x, y: this.modifySelected[0].y }); + const factor = refDist > 0 ? dist / refDist : 1; + this.state.previewElement = scaleElement(this.modifySelected[0], base.x, base.y, factor, factor); + } else if (tool === 'mirror') { + // Mirror preview: axis from base point to current mouse position + this.state.previewElement = mirrorElement(this.modifySelected[0], base.x, base.y, pt.x, pt.y); + } + } + + private confirmModify(pt: { x: number; y: number }): void { + if (this.modifySelected.length === 0 || this.state.points.length === 0) return; + const tool = this.state.activeTool; + const base = this.state.points[0]; + + if (tool === 'move') { + const dx = pt.x - base.x; + const dy = pt.y - base.y; + const modified = this.modifySelected.map(el => moveElement(el, dx, dy)); + this.onElementsModified?.(modified); + } else if (tool === 'copy') { + const dx = pt.x - base.x; + const dy = pt.y - base.y; + for (const el of this.modifySelected) { + const copy = moveElement(el, dx, dy); + this.onElementCreated?.({ ...copy, id: this.generateId() }); + } + } else if (tool === 'rotate') { + const angle = angleBetween(base, pt); + const modified = this.modifySelected.map(el => rotateElement(el, base.x, base.y, angle)); + this.onElementsModified?.(modified); + } else if (tool === 'scale') { + const dist = distance(base, pt); + const refDist = distance(base, { x: this.modifySelected[0].x, y: this.modifySelected[0].y }); + const factor = refDist > 0 ? dist / refDist : 1; + const modified = this.modifySelected.map(el => scaleElement(el, base.x, base.y, factor, factor)); + this.onElementsModified?.(modified); + } else if (tool === 'mirror') { + // Mirror axis: base point (first click) to current point (second click) + const modified = this.modifySelected.map(el => mirrorElement(el, base.x, base.y, pt.x, pt.y)); + this.onElementsModified?.(modified); + } + + this.resetModify(); + } + + private resetModify(): void { + this.modifySelected = []; + this.modifyBoundary = null; + this.state.points = []; + this.state.previewElement = null; + this.state.phase = 'idle'; + this.notifyToolStateChanged(); + this.requestRender(); + } + + private handleDeleteDown(world: { x: number; y: number }): void { + const hit = this.renderEngine.hitTest(world.x, world.y, 5); + if (hit) { + this.onElementsDeleted?.([hit.id]); + } + } + + private updatePreview(pt: { x: number; y: number }): void { + if (this.state.points.length === 0) return; + const layerId = this.layerManager.getActiveLayerId(); + const first = this.state.points[0]; + const tool = this.state.activeTool; + + switch (tool) { + case 'line': + this.state.previewElement = { + id: '__preview__', + type: 'line', + layerId, + x: (first.x + pt.x) / 2, + y: (first.y + pt.y) / 2, + width: Math.abs(pt.x - first.x), + height: Math.abs(pt.y - first.y), + properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y }, + }; + break; + case 'rect': { + const minX = Math.min(first.x, pt.x); + const minY = Math.min(first.y, pt.y); + const w = Math.abs(pt.x - first.x); + const h = Math.abs(pt.y - first.y); + this.state.previewElement = { + id: '__preview__', + type: 'rect', + layerId, + x: minX + w / 2, + y: minY + h / 2, + width: w, + height: h, + properties: {}, + }; + break; + } + case 'circle': { + const r = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); + this.state.previewElement = { + id: '__preview__', + type: 'circle', + layerId, + x: first.x, + y: first.y, + width: r * 2, + height: r * 2, + properties: { radius: r }, + }; + break; + } + case 'arc': { + // 3-point arc: click 1 = center, click 2 = start point (radius + startAngle), click 3 = end point (endAngle) + const pts = this.state.points; + if (pts.length === 1) { + // After center placed, show preview circle as radius guide + const r = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); + this.state.previewElement = { + id: '__preview__', + type: 'arc', + layerId, + x: first.x, + y: first.y, + width: r * 2, + height: r * 2, + properties: { radius: r, startAngle: 0, endAngle: 360 }, + }; + } else if (pts.length >= 2) { + const center = pts[0]; + const start = pts[1]; + const radius = Math.sqrt((start.x - center.x) ** 2 + (start.y - center.y) ** 2); + const startAngle = (Math.atan2(start.y - center.y, start.x - center.x) * 180) / Math.PI; + const endAngle = (Math.atan2(pt.y - center.y, pt.x - center.x) * 180) / Math.PI; + this.state.previewElement = { + id: '__preview__', + type: 'arc', + layerId, + x: center.x, + y: center.y, + width: radius * 2, + height: radius * 2, + properties: { radius, startAngle, endAngle }, + }; + } + break; + } + case 'polyline': + case 'polygon': { + const pts = [...this.state.points, pt]; + const xs = pts.map(p => p.x); + const ys = pts.map(p => p.y); + this.state.previewElement = { + id: '__preview__', + type: tool, + layerId, + x: (Math.min(...xs) + Math.max(...xs)) / 2, + y: (Math.min(...ys) + Math.max(...ys)) / 2, + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + properties: { points: pts }, + }; + break; + } + case 'text': + // Text placement — single point, then user types + this.state.previewElement = { + id: '__preview__', + type: 'text', + layerId, + x: pt.x, + y: pt.y, + width: 100, + height: 20, + properties: { text: '', fontSize: 12 }, + }; + break; + case 'dimension': { + const dist = Math.sqrt((pt.x - first.x) ** 2 + (pt.y - first.y) ** 2); + this.state.previewElement = { + id: '__preview__', + type: 'dimension', + layerId, + x: (first.x + pt.x) / 2, + y: (first.y + pt.y) / 2, + width: dist, + height: 20, + properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y }, + }; + break; + } + case 'leader': { + this.state.previewElement = { + id: '__preview__', + type: 'leader', + layerId, + x: pt.x, y: pt.y, + width: Math.abs(pt.x - first.x), + height: Math.abs(pt.y - first.y), + properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y, text: '', fontSize: 12, stroke: '#e0e0e0', strokeWidth: 1 }, + }; + break; + } + case 'revcloud': { + const pts = [...this.state.points, pt]; + const xs = pts.map(p => p.x); + const ys = pts.map(p => p.y); + this.state.previewElement = { + id: '__preview__', + type: 'revcloud', + layerId, + x: (Math.min(...xs) + Math.max(...xs)) / 2, + y: (Math.min(...ys) + Math.max(...ys)) / 2, + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + properties: { points: pts, arcHeight: 8, fill: 'none', stroke: '#e0e0e0', strokeWidth: 1.5 }, + }; + break; + } + case 'zoom-win': + case 'measure': { + // Show a dashed line preview from first point to cursor + this.state.previewElement = { + id: '__preview__', + type: 'line', + layerId, + x: (first.x + pt.x) / 2, + y: (first.y + pt.y) / 2, + width: Math.abs(pt.x - first.x), + height: Math.abs(pt.y - first.y), + properties: { x1: first.x, y1: first.y, x2: pt.x, y2: pt.y }, + }; + break; + } + default: + break; + } + } + + private confirmDraw(): void { + if (!this.state.previewElement) return; + const layerId = this.layerManager.getActiveLayerId(); + const dimService = new DimensionService(); + let el: CADElement; + + // Use DimensionService for leader and revcloud creation + if (this.state.activeTool === 'leader' && this.state.points.length >= 2) { + const p1 = this.state.points[0]; + const p2 = this.state.points[1]; + el = dimService.createLeader(p1.x, p1.y, p2.x, p2.y, layerId, { text: 'Hinweis' }); + } else if (this.state.activeTool === 'revcloud' && this.state.points.length >= 2) { + el = dimService.createRevCloud(this.state.points, layerId); + } else { + el = { ...this.state.previewElement, id: this.generateId(), layerId: layerId }; + } + + this.onElementCreated?.(el); + + // Text: trigger edit callback after placement + if (el.type === 'text') { + this.onTextEdit?.(el); + } + + // Leader: trigger edit callback for text input + if (el.type === 'leader') { + this.onTextEdit?.(el); + } + + this.state.points = []; + this.state.previewElement = null; + this.state.phase = 'idle'; + this.notifyToolStateChanged(); + this.requestRender(); + } + + private cancelCurrentOperation(): void { + this.state.points = []; + this.state.previewElement = null; + this.state.phase = 'idle'; + this.selectionEngine.cancelBoxSelect(); + this.notifyToolStateChanged(); + this.requestRender(); + } + + private notifyToolStateChanged(): void { + this.onToolStateChanged?.(this.getToolState()); + } + + private requestRender(): void { + if (this.animationFrame !== null) return; + this.animationFrame = requestAnimationFrame(() => { + this.animationFrame = null; + this.renderEngine.setPreviewElement(this.state.previewElement); + this.renderEngine.render(); + }); + } + + private generateId(): string { + return `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + } + + // Public method to trigger zoom fit + zoomFit(): void { + this.zoomPan.zoomFit(this.allElements); + this.requestRender(); + } + + // Get current cursor world position + getCursorWorld(): { x: number; y: number } { + return this.zoomPan.screenToWorld(this.lastMouseScreen.x, this.lastMouseScreen.y); + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx deleted file mode 100644 index 5cc5991..0000000 --- a/frontend/src/main.jsx +++ /dev/null @@ -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( - - - , -) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..8f0d42c --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + , +); diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx deleted file mode 100644 index ea85654..0000000 --- a/frontend/src/pages/Dashboard.jsx +++ /dev/null @@ -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
Lade...
; - - return ( -
-
-

Meine Zeichnungen ({user?.email})

- -
- - {drawings.length === 0 ? ( -

Keine Zeichnungen vorhanden.

- ) : ( - - - - - - {drawings.map(d => ( - - - - - - ))} - -
NameZuletzt geändertAktionen
{d.name}{new Date(d.updatedAt).toLocaleString()} - - -
- )} -
- ); -}; - -export default Dashboard; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..abb0379 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -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([]); + 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 ( +
+
+
+

Web CAD

+ {user?.name} ({user?.role}) +
+ +
+ +
+
+

Projekte

+ +
+ + {showCreate && ( +
+ setNewName(e.target.value)} + autoFocus + /> + setNewDesc(e.target.value)} + /> + + +
+ )} + + {loading ? ( +

Lade Projekte…

+ ) : projects.length === 0 ? ( +

Noch keine Projekte. Erstellen Sie ein neues Projekt.

+ ) : ( +
+ {projects.map(project => ( +
onOpenProject(project.id)} + > +

{project.name}

+ {project.description &&

{project.description}

} +
+ Erstellt: {new Date(project.created_at).toLocaleDateString('de-DE')} + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Editor.jsx b/frontend/src/pages/Editor.jsx deleted file mode 100644 index 0b5c1a1..0000000 --- a/frontend/src/pages/Editor.jsx +++ /dev/null @@ -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 ( -
- {/* Toolbar */} -
- - -
- - {/* Canvas area */} -
-
- - - - - - {saveStatus} -
- - -
- - {/* Right panels */} -
-
setShowLayers(!showLayers)}> - Layer {showLayers ? '▲' : '▼'} -
- {showLayers && ( - - )} -
setShowBlocks(!showBlocks)}> - Teilebibliothek {showBlocks ? '▲' : '▼'} -
- {showBlocks && ( - - )} -
-
- ); -}; - -export default Editor; diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx deleted file mode 100644 index 2c18392..0000000 --- a/frontend/src/pages/Login.jsx +++ /dev/null @@ -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 ( -
-

Web-CAD Anmeldung

- {error &&

{error}

} -
-
-
- setEmail(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} /> -
-
-
- setPassword(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} /> -
- -
-

- Kein Konto? Registrieren -

-
- ); -}; - -export default Login; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..eed8560 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -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 ( +
+
+

Web CAD

+

Anmelden

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + required + autoFocus + placeholder="name@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + placeholder="••••••••" + /> +
+ + +
+ +

+ Noch kein Konto?{' '} + +

+
+
+ ); +} diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx deleted file mode 100644 index 1ceb0ce..0000000 --- a/frontend/src/pages/Register.jsx +++ /dev/null @@ -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 ( -
-

Registrierung

- {error &&

{error}

} -
-
-
- setEmail(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} /> -
-
-
- setPassword(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} /> -
-
-
- setPasswordConfirm(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} /> -
- -
-

- Bereits registriert? Anmelden -

-
- ); -}; - -export default Register; diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx new file mode 100644 index 0000000..42499b5 --- /dev/null +++ b/frontend/src/pages/Register.tsx @@ -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(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 ( +
+
+

Web CAD

+

Registrieren

+ + {displayError && ( +
{ clearError(); setLocalError(null); }}> + {displayError} +
+ )} + +
+
+ + setName(e.target.value)} + required + autoFocus + placeholder="Ihr Name" + /> +
+ +
+ + setEmail(e.target.value)} + required + placeholder="name@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + placeholder="min. 6 Zeichen" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + placeholder="••••••••" + /> +
+ + +
+ +

+ Bereits ein Konto?{' '} + +

+
+
+ ); +} diff --git a/frontend/src/plugins/PluginRegistry.ts b/frontend/src/plugins/PluginRegistry.ts new file mode 100644 index 0000000..4e9b226 --- /dev/null +++ b/frontend/src/plugins/PluginRegistry.ts @@ -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(); + private states = new Map(); + 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(); diff --git a/frontend/src/plugins/builtin/eventTools.ts b/frontend/src/plugins/builtin/eventTools.ts new file mode 100644 index 0000000..f131928 --- /dev/null +++ b/frontend/src/plugins/builtin/eventTools.ts @@ -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 [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'); + }, +}; diff --git a/frontend/src/plugins/index.ts b/frontend/src/plugins/index.ts new file mode 100644 index 0000000..e6a419c --- /dev/null +++ b/frontend/src/plugins/index.ts @@ -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); +} diff --git a/frontend/src/plugins/types.ts b/frontend/src/plugins/types.ts new file mode 100644 index 0000000..d2bed86 --- /dev/null +++ b/frontend/src/plugins/types.ts @@ -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; + /** 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) => 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; +} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js deleted file mode 100644 index a891b2d..0000000 --- a/frontend/src/services/api.js +++ /dev/null @@ -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; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..f3febd3 --- /dev/null +++ b/frontend/src/services/api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await fetch(`${API_BASE}/api/projects/${id}`, { + method: 'DELETE', + headers: authHeaders(token), + }); +} + +// ─── Drawings ─────────────────────────────────────────── +export async function getDrawings(token: string, projectId: string): Promise { + 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 { + 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 { + 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 { + 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): Promise { + 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 { + await fetch(`${API_BASE}/api/elements/${elementId}`, { + method: 'DELETE', + headers: authHeaders(token), + }); +} + +// ─── Layers ───────────────────────────────────────────── +export async function getLayers(token: string, drawingId: string): Promise { + 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 { + 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): Promise { + 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 { + await fetch(`${API_BASE}/api/layers/${layerId}`, { + method: 'DELETE', + headers: authHeaders(token), + }); +} + +// ─── Blocks ───────────────────────────────────────────── +export async function getBlocks(token: string, drawingId: string): Promise { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + const raw = await getElements(token, drawingId); + return raw.map(dbElementToFrontend); +} + +export async function createElementTyped(token: string, drawingId: string, el: CADElement): Promise { + const raw = await createElement(token, drawingId, frontendElementToDb(el, drawingId)); + return dbElementToFrontend(raw); +} + +export async function getLayersTyped(token: string, drawingId: string): Promise { + const raw = await getLayers(token, drawingId); + return raw.map(dbLayerToFrontend); +} + +export async function createLayerTyped(token: string, drawingId: string, layer: CADLayer): Promise { + const raw = await createLayer(token, drawingId, frontendLayerToDb(layer, drawingId)); + return dbLayerToFrontend(raw); +} + +export async function getBlocksTyped(token: string, drawingId: string): Promise { + const raw = await getBlocks(token, drawingId); + return raw.map(dbBlockToFrontend); +} + +export async function createBlockTyped(token: string, drawingId: string, block: BlockDefinition): Promise { + const raw = await createBlock(token, drawingId, frontendBlockToDb(block, drawingId)); + return dbBlockToFrontend(raw); +} + +const projectLoadCache = new Map>(); + +export async function loadProjectDataTyped(token: string, projectId: string): Promise { + // 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; +} + +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(); +} diff --git a/frontend/src/services/backgroundService.ts b/frontend/src/services/backgroundService.ts new file mode 100644 index 0000000..9c3315b --- /dev/null +++ b/frontend/src/services/backgroundService.ts @@ -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 { + 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 { + 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 { + 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): 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 { + 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; + }); + } +} diff --git a/frontend/src/services/blockService.js b/frontend/src/services/blockService.js deleted file mode 100644 index 14c91b3..0000000 --- a/frontend/src/services/blockService.js +++ /dev/null @@ -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; - } -}; diff --git a/frontend/src/services/blockService.ts b/frontend/src/services/blockService.ts new file mode 100644 index 0000000..263008e --- /dev/null +++ b/frontend/src/services/blockService.ts @@ -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 = 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 } }, + ], + }, + ]; +} diff --git a/frontend/src/services/commandRegistry.ts b/frontend/src/services/commandRegistry.ts new file mode 100644 index 0000000..a10d199 --- /dev/null +++ b/frontend/src/services/commandRegistry.ts @@ -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 = 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(); + 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; +} diff --git a/frontend/src/services/dimensionService.ts b/frontend/src/services/dimensionService.ts new file mode 100644 index 0000000..4cba9c0 --- /dev/null +++ b/frontend/src/services/dimensionService.ts @@ -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 = {}, + ): 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 = {}, + ): 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 = {}, + ): 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 = {}): 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 = {}): 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 = {}): 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}`; + } +} diff --git a/frontend/src/services/dxfParser.ts b/frontend/src/services/dxfParser.ts new file mode 100644 index 0000000..745d30e --- /dev/null +++ b/frontend/src/services/dxfParser.ts @@ -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(); + 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, +): 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 = { + 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'; +} diff --git a/frontend/src/services/dxfWriter.ts b/frontend/src/services/dxfWriter.ts new file mode 100644 index 0000000..139ca6f --- /dev/null +++ b/frontend/src/services/dxfWriter.ts @@ -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; +} diff --git a/frontend/src/services/exportService.ts b/frontend/src/services/exportService.ts new file mode 100644 index 0000000..5222712 --- /dev/null +++ b/frontend/src/services/exportService.ts @@ -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 { + 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 { + 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 { + // 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((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(``); + lines.push(``); + + 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(``); + 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 ``; + } + case 'circle': { + const cx = el.x + el.width / 2; + const cy = el.y + el.height / 2; + const r = p.radius ?? el.width / 2; + return ``; + } + 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 ``; + } + case 'rect': { + return ``; + } + 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 `${escapeXml(p.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 `\n${dist.toFixed(2)}`; + } + default: { + return ``; + } + } +} + +function drawElementToCanvas(ctx: CanvasRenderingContext2D, el: CADElement, layerColor?: string): void { + const p = el.properties; + const stroke = (p.stroke as string) || layerColor || '#000000'; + ctx.strokeStyle = stroke; + ctx.lineWidth = p.strokeWidth ?? 1; + ctx.fillStyle = (p.fill as string) || 'transparent'; + + switch (el.type) { + case 'line': + ctx.beginPath(); + ctx.moveTo(p.x1 ?? el.x, p.y1 ?? el.y); + ctx.lineTo(p.x2 ?? el.x + el.width, p.y2 ?? el.y + el.height); + ctx.stroke(); + break; + case 'circle': + ctx.beginPath(); + ctx.arc(el.x + el.width / 2, el.y + el.height / 2, p.radius ?? el.width / 2, 0, Math.PI * 2); + ctx.stroke(); + break; + case 'rect': + ctx.strokeRect(el.x, el.y, el.width, el.height); + break; + case 'polygon': + case 'polyline': { + const pts = p.points ?? []; + if (pts.length < 2) break; + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); + if (el.type === 'polygon') ctx.closePath(); + ctx.stroke(); + break; + } + case 'text': + ctx.font = `${p.fontSize ?? 12}px sans-serif`; + ctx.fillStyle = stroke; + ctx.fillText(p.text ?? '', el.x, el.y); + break; + default: + ctx.strokeRect(el.x, el.y, el.width, el.height); + break; + } +} + +function calculateBoundingBox(elements: CADElement[]) { + if (elements.length === 0) return { minX: 0, minY: 0, maxX: 1000, maxY: 1000 }; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const el of elements) { + minX = Math.min(minX, el.x); + minY = Math.min(minY, el.y); + maxX = Math.max(maxX, el.x + el.width); + maxY = Math.max(maxY, el.y + el.height); + } + return { minX, minY, maxX, maxY }; +} + +function escapeXml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** + * 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); +} diff --git a/frontend/src/services/importService.ts b/frontend/src/services/importService.ts new file mode 100644 index 0000000..98ca2ab --- /dev/null +++ b/frontend/src/services/importService.ts @@ -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 { + 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)}`, + }; + } +} diff --git a/frontend/src/services/pdfExport.ts b/frontend/src/services/pdfExport.ts new file mode 100644 index 0000000..8bfbac6 --- /dev/null +++ b/frontend/src/services/pdfExport.ts @@ -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 { + 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 | 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); +} diff --git a/frontend/src/services/seatingService.ts b/frontend/src/services/seatingService.ts new file mode 100644 index 0000000..49fa6bb --- /dev/null +++ b/frontend/src/services/seatingService.ts @@ -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; +} + +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 = {}): 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 = {}): 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 = {}): 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 = {}): 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 = {}): 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); + } + if (template.type === 'block') { + return this.createSeatingBlock(x, y, layerId, template.config as Partial); + } + if (template.type === 'mixed') { + const blocks = (template.config as { blocks: Array> }).blocks; + const elements: CADElement[] = []; + for (const blk of blocks) { + const bx = x + (blk.offsetX || 0); + const cfg: Partial = { + 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; byBlock: Record } { + let total = 0; + const byRow: Record = {}; + const byBlock: Record = {}; + + 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]; + } +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..72b6fce --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,2012 @@ +/* web-cad v6 – Design Tokens, Typography, Base (with Drawer tokens) */ +:root { + --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; + + --fs-xs: 11px; + --fs-sm: 12px; + --fs-md: 13px; + --fs-lg: 14px; + --fs-xl: 16px; + --fs-2xl: 20px; + + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-primary-light: #dbeafe; + --color-primary-50: #eff6ff; + + --color-bg: #f8fafc; + --color-surface: #ffffff; + --color-surface-2: #f1f5f9; + --color-surface-3: #e2e8f0; + --color-border: #e2e8f0; + --color-border-strong: #cbd5e1; + + --color-text: #0f172a; + --color-text-muted: #64748b; + --color-text-faint: #94a3b8; + + --color-success: #10b981; + --color-warning: #f59e0b; + --color-error: #ef4444; + --color-ki: #8b5cf6; + --color-ki-light: #ede9fe; + + --color-canvas-bg: #0f172a; + --color-canvas-bg-2: #1e293b; + --color-grid-minor: rgba(255,255,255,0.06); + --color-grid-major: rgba(255,255,255,0.12); + --color-grid-axis: rgba(96,165,250,0.28); + --color-online: #10b981; + + --radius-xs: 3px; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + + --spacing-xs: 4px; + --spacing-sm: 6px; + --spacing-md: 10px; + --spacing-lg: 14px; + --spacing-xl: 20px; + + /* Layout */ + --topbar-h: 38px; + --ribbon-h: 72px; + --leftbar-w: 200px; + --rightbar-w: 300px; + --cmdline-h: 56px; + --status-h: 26px; + --canvas-toolbar-h: 38px; + + /* Mobile-specific */ + --mobile-right-tab-w: 56px; + --mobile-topbar-h: 44px; + --mobile-ribbon-h: 48px; + --drawer-w: min(320px, 88vw); + + --shadow-sm: 0 1px 2px 0 rgba(15,23,42,0.04); + --shadow-md: 0 2px 6px -1px rgba(15,23,42,0.08), 0 1px 3px -1px rgba(15,23,42,0.06); + --shadow-lg: 0 10px 20px -4px rgba(15,23,42,0.10), 0 4px 8px -2px rgba(15,23,42,0.06); + --shadow-drawer: -8px 0 24px -4px rgba(15,23,42,0.18); + + --t-fast: 120ms cubic-bezier(.2,.0,.2,1); + --t-base: 200ms cubic-bezier(.2,.0,.2,1); + --t-drawer: 280ms cubic-bezier(.32, .72, 0, 1); +} + +[data-theme="dark"] { + --color-bg: #0b0e14; + --color-surface: #11151c; + --color-surface-2: #161b24; + --color-surface-3: #1d2330; + --color-border: #232a36; + --color-border-strong: #2f3849; + --color-text: #e2e8f0; + --color-text-muted: #94a3b8; + --color-text-faint: #64748b; + --color-primary-light: #1e3a8a; + --color-primary-50: #172554; + --color-ki-light: #2e1065; + --shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.3); + --shadow-md: 0 2px 6px -1px rgba(0,0,0,0.4); + --shadow-lg: 0 10px 20px -4px rgba(0,0,0,0.5); + --shadow-drawer: -8px 0 24px -4px rgba(0,0,0,0.6); +} + +* { box-sizing: border-box; } +*::before, *::after { box-sizing: border-box; } + +html, body { + height: 100%; + margin: 0; + font-family: var(--font-sans); + font-size: var(--fs-md); + background: var(--color-bg); + color: var(--color-text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.5; + overflow: hidden; + /* iOS safe areas */ + padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); +} + +body { + position: fixed; + inset: 0; + width: 100%; +} + +button { + font-family: inherit; + font-size: inherit; + cursor: pointer; + background: transparent; + border: none; + color: inherit; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +input, select, textarea { + font-family: inherit; + font-size: inherit; +} + +a { color: var(--color-primary); text-decoration: none; } +a:hover { text-decoration: underline; } + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--color-border-strong); + border-radius: 10px; + border: 2px solid var(--color-bg); +} +::-webkit-scrollbar-thumb:hover { background: var(--color-text-faint); } + +*:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + top: -40px; + left: 8px; + background: var(--color-primary); + color: white; + padding: 8px 12px; + border-radius: var(--radius-md); + z-index: 1000; + font-weight: 500; +} +.skip-link:focus { top: 8px; } + +/* Scrim (drawer backdrop) */ +.scrim { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.5); + z-index: 90; + opacity: 0; + visibility: hidden; + transition: opacity var(--t-drawer), visibility 0s linear var(--t-drawer); +} +.scrim.show { + opacity: 1; + visibility: visible; + transition: opacity var(--t-drawer), visibility 0s linear 0s; +} +/* web-cad v6 – Editor Layout (topbar, ribbon, sidebars, canvas, footer) + MOBILE responsive */ + +/* ============================================================ + APP GRID (desktop default) + ============================================================ */ +.app { + display: grid; + grid-template-rows: var(--topbar-h) var(--ribbon-h) 1fr var(--cmdline-h) var(--status-h); + grid-template-columns: 100%; + height: 100vh; + height: 100dvh; + overflow: hidden; + background: var(--color-bg); +} + +/* ============================================================ + TOPBAR + ============================================================ */ +.topbar { + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--spacing-md); + height: var(--topbar-h); + z-index: 30; + flex-shrink: 0; +} +.topbar-left, .topbar-right { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} +.app-logo { + width: 24px; + height: 24px; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-ki) 100%); + border-radius: var(--radius-sm); + display: grid; + place-items: center; + color: white; +} +.app-logo svg { width: 14px; height: 14px; } +.app-name { font-weight: 700; font-size: var(--fs-lg); letter-spacing: -0.2px; } +.project-name { + display: flex; + align-items: center; + gap: var(--spacing-xs); + font-weight: 500; + color: var(--color-text); + padding: 4px 8px; + border-radius: var(--radius-sm); + transition: background var(--t-fast); + background: transparent; + border: none; +} +.project-name:hover { background: var(--color-surface-2); } +.project-name .caret { color: var(--color-text-faint); } +.saved-badge { + font-size: var(--fs-xs); + color: var(--color-text-muted); + padding: 2px 8px; + background: var(--color-surface-2); + border-radius: 999px; + display: inline-flex; + align-items: center; + gap: 4px; +} +.saved-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-success); +} + +.icon-btn-top { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + transition: all var(--t-fast); +} +.icon-btn-top:hover { background: var(--color-surface-2); color: var(--color-text); } +.icon-btn-top svg { width: 16px; height: 16px; } +.icon-btn-top.danger { color: var(--color-error); } + +.lang-select { + height: 28px; + padding: 0 8px; + border: 1px solid var(--color-border); + background: var(--color-surface); + border-radius: var(--radius-sm); + font-size: var(--fs-sm); + color: var(--color-text); + cursor: pointer; +} +.avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: linear-gradient(135deg, #f59e0b, #ef4444); + color: white; + display: grid; + place-items: center; + font-weight: 600; + font-size: var(--fs-xs); + cursor: pointer; +} + +/* Hamburger button (mobile only, HIDDEN by default) */ +.hamburger-btn { + display: none; + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + align-items: center; + justify-content: center; + color: var(--color-text); +} +.hamburger-btn:hover { background: var(--color-surface-2); } +.hamburger-btn svg { width: 20px; height: 20px; } + +/* Right vertical tab bar (mobile only, HIDDEN by default on desktop) */ +.right-tab-bar { display: none; } + +/* Drawers + scrim (mobile only, HIDDEN by default on desktop) */ +.drawer { display: none; } +.scrim { display: none; } + +/* ============================================================ + RIBBON + ============================================================ */ +.ribbon { + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + display: flex; + flex-direction: column; + height: var(--ribbon-h); + z-index: 25; + flex-shrink: 0; +} +.ribbon-tabs { + display: flex; + height: 30px; + border-bottom: 1px solid var(--color-border); + padding: 0 var(--spacing-sm); + gap: 2px; + overflow-x: auto; + scrollbar-width: none; +} +.ribbon-tabs::-webkit-scrollbar { display: none; } +.ribbon-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + height: 100%; + font-size: var(--fs-sm); + color: var(--color-text-muted); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: all var(--t-fast); + font-weight: 500; + flex-shrink: 0; +} +.ribbon-tab:hover { color: var(--color-text); background: var(--color-surface-2); } +.ribbon-tab.active { color: var(--color-primary); border-bottom-color: var(--color-primary); } +.ribbon-tab svg { width: 14px; height: 14px; } + +.ribbon-content { + flex: 1; + display: flex; + align-items: center; + padding: 0 var(--spacing-md); + gap: var(--spacing-lg); + overflow: hidden; +} +.ribbon-group { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + padding: 4px 0; + position: relative; +} +.ribbon-group-btns { display: flex; align-items: center; gap: 2px; flex: 1; } +.ribbon-group-label { + font-size: 10px; + color: var(--color-text-faint); + text-transform: uppercase; + letter-spacing: 0.4px; + border-top: 1px solid var(--color-border); + width: 100%; + text-align: center; + padding-top: 3px; + margin-top: 2px; +} +.ribbon-divider { + width: 1px; + height: 60%; + background: var(--color-border); + align-self: center; + flex-shrink: 0; +} + +.ribbon-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 44px; + height: 38px; + padding: 4px 6px; + border-radius: var(--radius-sm); + color: var(--color-text); + font-size: 10px; + font-weight: 500; + transition: all var(--t-fast); + flex-shrink: 0; +} +.ribbon-btn:hover { background: var(--color-primary-50); color: var(--color-primary); } +[data-theme="dark"] .ribbon-btn:hover { background: var(--color-surface-3); color: var(--color-primary); } +.ribbon-btn:active { transform: scale(0.97); } +.ribbon-btn svg { width: 18px; height: 18px; } + +/* ============================================================ + MAIN AREA (desktop 3-column) + ============================================================ */ +.app-body { + display: grid; + grid-template-columns: var(--leftbar-w) 1fr var(--rightbar-w); + min-height: 0; + background: var(--color-bg); + overflow: hidden; + position: relative; +} +.app-body.left-collapsed { grid-template-columns: 28px 1fr var(--rightbar-w); } +.app-body.left-collapsed .leftbar { padding: 4px; } +.app-body.left-collapsed .leftbar-header { padding: 4px; border: none; justify-content: center; } +.app-body.left-collapsed .leftbar-title { display: none; } +.app-body.left-collapsed .leftbar .tool-section { display: none; } +.app-body.left-collapsed .leftbar-toggle svg { transform: rotate(180deg); } +.app-body.left-collapsed .leftbar-toggle { display: grid !important; } +.app-body.right-collapsed { grid-template-columns: var(--leftbar-w) 1fr 0; } +.app-body.both-collapsed { grid-template-columns: 0 1fr 0; } + +/* ============================================================ + LEFT SIDEBAR (Desktop tool palette 2-col) + ============================================================ */ +.leftbar { + background: var(--color-surface); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} +.leftbar-header { + padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm); + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; +} +.leftbar-title { + font-size: var(--fs-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-muted); +} +.leftbar-toggle { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border-radius: var(--radius-xs); + color: var(--color-text-muted); + transition: all var(--t-fast); +} +.leftbar-toggle:hover { background: var(--color-surface-2); color: var(--color-text); } +.leftbar-toggle svg { width: 14px; height: 14px; } + +.tool-section { + padding: var(--spacing-sm) var(--spacing-sm); + border-bottom: 1px solid var(--color-border); +} +.tool-section-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-faint); + padding: 0 6px 6px; +} +.tool-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; +} +.tool-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + padding: 8px 4px; + border-radius: var(--radius-md); + color: var(--color-text-muted); + transition: all var(--t-fast); + position: relative; + min-height: 56px; +} +.tool-btn:hover { background: var(--color-surface-2); color: var(--color-text); } +.tool-btn.active { + background: var(--color-primary); + color: white; + box-shadow: 0 2px 4px -1px rgba(37,99,235,0.4); +} +.tool-btn svg { width: 20px; height: 20px; stroke-width: 1.8; } +.tool-btn-label { + font-size: 10px; + font-weight: 500; + line-height: 1; + white-space: nowrap; +} +.tool-btn-kbd { + position: absolute; + top: 2px; + right: 4px; + font-size: 9px; + font-weight: 600; + color: var(--color-text-faint); + background: var(--color-surface-2); + border-radius: 2px; + padding: 0 3px; + font-family: var(--font-mono); + opacity: 0; + transition: opacity var(--t-fast); +} +.tool-btn:hover .tool-btn-kbd { opacity: 1; } +.tool-btn.active .tool-btn-kbd { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.15); } + +.leftbar-footer { + margin-top: auto; + padding: var(--spacing-sm); + border-top: 1px solid var(--color-border); + display: flex; + justify-content: space-around; +} +.leftbar-footer-btn { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + transition: all var(--t-fast); +} +.leftbar-footer-btn:hover { background: var(--color-surface-2); color: var(--color-text); } +.leftbar-footer-btn svg { width: 16px; height: 16px; } + +/* ============================================================ + CANVAS AREA + ============================================================ */ +.canvas-area { + position: relative; + background: var(--color-canvas-bg); + overflow: hidden; + min-width: 0; + display: flex; + flex-direction: column; +} + +.canvas-toolbar { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 5; + display: flex; + align-items: center; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + height: var(--canvas-toolbar-h); + padding: 0 4px; + gap: 2px; +} +.canvas-toolbar-btn { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + color: var(--color-text); + transition: all var(--t-fast); +} +.canvas-toolbar-btn:hover { background: var(--color-surface-2); } +.canvas-toolbar-btn.active { background: var(--color-primary); color: white; } +.canvas-toolbar-btn svg { width: 16px; height: 16px; } +.canvas-toolbar-divider { + width: 1px; + height: 18px; + background: var(--color-border); + margin: 0 4px; +} +.canvas-zoom-display { + font-size: var(--fs-sm); + font-weight: 600; + font-family: var(--font-mono); + padding: 0 8px; + color: var(--color-text); + min-width: 52px; + text-align: center; +} + +.canvas-coords { + position: absolute; + top: 12px; + left: 12px; + z-index: 5; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 6px 10px; + font-family: var(--font-mono); + font-size: var(--fs-xs); + color: var(--color-text); + box-shadow: var(--shadow-sm); + display: flex; + align-items: center; + gap: 12px; +} +.canvas-coords-label { color: var(--color-text-muted); margin-right: 4px; } +.canvas-coords-val { font-weight: 600; } + +.canvas-view-tabs { + position: absolute; + top: 12px; + right: 12px; + z-index: 5; + display: flex; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-sm); +} +.canvas-view-tab { + padding: 6px 10px; + font-size: var(--fs-xs); + font-weight: 600; + color: var(--color-text-muted); + border-right: 1px solid var(--color-border); + transition: all var(--t-fast); +} +.canvas-view-tab:last-child { border-right: none; } +.canvas-view-tab:hover { background: var(--color-surface-2); color: var(--color-text); } +.canvas-view-tab.active { background: var(--color-primary); color: white; } + +.canvas-svg { + flex: 1; + width: 100%; + height: 100%; + display: block; + cursor: crosshair; +} + +/* ============================================================ + RIGHT SIDEBAR (Desktop tabs + content) + ============================================================ */ +.rightbar { + background: var(--color-surface); + border-left: 1px solid var(--color-border); + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} +.rightbar-tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-2); +} +.rightbar-tab { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 8px 4px; + font-size: 10px; + font-weight: 500; + color: var(--color-text-muted); + border-bottom: 2px solid transparent; + transition: all var(--t-fast); + position: relative; +} +.rightbar-tab:hover { color: var(--color-text); background: var(--color-surface); } +.rightbar-tab.active { + color: var(--color-primary); + background: var(--color-surface); + border-bottom-color: var(--color-primary); +} +.rightbar-tab svg { width: 18px; height: 18px; } +.rightbar-tab-badge { + position: absolute; + top: 4px; + right: 8px; + background: var(--color-ki); + color: white; + font-size: 9px; + font-weight: 700; + border-radius: 999px; + padding: 1px 5px; + line-height: 1.3; +} + +.rightbar-content { + flex: 1; + overflow-y: auto; + padding: var(--spacing-md); +} +.rightbar-panel { display: none; } +.rightbar-panel.active { display: block; } + +.panel-section { margin-bottom: var(--spacing-lg); } +.panel-section-title { + font-size: var(--fs-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-muted); + margin-bottom: var(--spacing-sm); + display: flex; + align-items: center; + justify-content: space-between; +} + +/* Property rows */ +.prop-row { + display: grid; + grid-template-columns: 90px 1fr; + align-items: center; + margin-bottom: var(--spacing-sm); + gap: var(--spacing-sm); +} +.prop-label { font-size: var(--fs-sm); color: var(--color-text-muted); } +.prop-input, .prop-select { + height: 30px; + padding: 0 8px; + border: 1px solid var(--color-border); + background: var(--color-surface); + border-radius: var(--radius-sm); + font-size: var(--fs-sm); + color: var(--color-text); + width: 100%; + font-family: inherit; +} +.prop-input:focus, .prop-select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); +} +.prop-color-row { display: flex; align-items: center; gap: 6px; } +.prop-color-swatch { + width: 22px; + height: 22px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-strong); + flex-shrink: 0; +} +.prop-swatch-input { + width: 30px; + height: 22px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: transparent; + cursor: pointer; +} + +/* Layer Manager */ +.layer-list { display: flex; flex-direction: column; gap: 2px; } +.layer-item { + display: grid; + grid-template-columns: 18px 18px 18px 1fr auto; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: var(--radius-sm); + font-size: var(--fs-sm); + transition: background var(--t-fast); +} +.layer-item:hover { background: var(--color-surface-2); } +.layer-item.active { background: var(--color-primary-50); color: var(--color-primary); } +[data-theme="dark"] .layer-item.active { background: var(--color-surface-3); } +.layer-item-name { font-weight: 500; } +.layer-item-count { font-size: 10px; color: var(--color-text-faint); font-family: var(--font-mono); } +.layer-vis-btn, .layer-lock-btn, .layer-color-btn { + width: 18px; + height: 18px; + display: grid; + place-items: center; + color: var(--color-text-muted); + border-radius: var(--radius-xs); + transition: all var(--t-fast); +} +.layer-vis-btn:hover, .layer-lock-btn:hover { background: var(--color-surface-3); color: var(--color-text); } +.layer-vis-btn.off { color: var(--color-text-faint); opacity: 0.4; } +.layer-vis-btn svg, .layer-lock-btn svg { width: 12px; height: 12px; } +.layer-color-btn { padding: 0; border: 1px solid var(--color-border); } + +.add-layer-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + height: 30px; + margin-top: var(--spacing-sm); + border: 1px dashed var(--color-border-strong); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + font-size: var(--fs-sm); + transition: all var(--t-fast); +} +.add-layer-btn:hover { + border-color: var(--color-primary); + color: var(--color-primary); + background: var(--color-primary-50); +} +[data-theme="dark"] .add-layer-btn:hover { background: var(--color-surface-3); } +.add-layer-btn svg { width: 14px; height: 14px; } + +/* Block Library */ +.lib-search { position: relative; margin-bottom: var(--spacing-md); } +.lib-search input { + width: 100%; + height: 32px; + padding: 0 8px 0 30px; + border: 1px solid var(--color-border); + background: var(--color-surface-2); + border-radius: var(--radius-sm); + font-size: var(--fs-sm); + color: var(--color-text); +} +.lib-search input:focus { + outline: none; + background: var(--color-surface); + border-color: var(--color-primary); +} +.lib-search-icon { + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + color: var(--color-text-faint); +} +.lib-search-icon svg { width: 14px; height: 14px; } + +.lib-categories { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-bottom: var(--spacing-md); +} +.lib-cat { + padding: 4px 10px; + font-size: var(--fs-xs); + font-weight: 500; + border-radius: 999px; + background: var(--color-surface-2); + color: var(--color-text-muted); + transition: all var(--t-fast); +} +.lib-cat:hover { background: var(--color-surface-3); color: var(--color-text); } +.lib-cat.active { background: var(--color-primary); color: white; } + +.lib-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.lib-item { + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 8px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + cursor: grab; + transition: all var(--t-fast); +} +.lib-item:hover { + border-color: var(--color-primary); + background: var(--color-primary-50); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} +[data-theme="dark"] .lib-item:hover { background: var(--color-surface-3); } +.lib-item-icon { + width: 100%; + aspect-ratio: 1; + display: grid; + place-items: center; + background: var(--color-surface); + border-radius: var(--radius-sm); + color: var(--color-text); +} +.lib-item-icon svg { width: 28px; height: 28px; } +.lib-item-name { + font-size: var(--fs-xs); + font-weight: 500; + text-align: center; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +/* KI Copilot */ +.ki-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--color-border); +} +.ki-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, var(--color-ki) 0%, var(--color-primary) 100%); + color: white; + display: grid; + place-items: center; +} +.ki-avatar svg { width: 16px; height: 16px; } +.ki-title { font-weight: 600; font-size: var(--fs-md); } +.ki-status { + margin-left: auto; + font-size: var(--fs-xs); + color: var(--color-success); + display: flex; + align-items: center; + gap: 4px; +} +.ki-status::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-success); +} + +.ki-suggestions { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--spacing-md); } +.ki-suggestion-title { + font-size: var(--fs-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-muted); + margin-bottom: 4px; +} +.ki-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + background: var(--color-ki-light); + color: var(--color-ki); + border-radius: var(--radius-md); + font-size: var(--fs-sm); + text-align: left; + transition: all var(--t-fast); + border: 1px solid transparent; +} +.ki-chip:hover { background: var(--color-ki); color: white; border-color: var(--color-ki); } +[data-theme="dark"] .ki-chip { background: var(--color-ki-light); } +.ki-chip svg { width: 14px; height: 14px; flex-shrink: 0; } + +.ki-chat { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: var(--spacing-md); +} +.ki-msg { display: flex; gap: 6px; align-items: flex-start; } +.ki-msg-bubble { + padding: 8px 10px; + border-radius: var(--radius-md); + font-size: var(--fs-sm); + line-height: 1.4; + max-width: 100%; +} +.ki-msg.user .ki-msg-bubble { + background: var(--color-primary); + color: white; + border-bottom-right-radius: 2px; +} +.ki-msg.assistant .ki-msg-bubble { + background: var(--color-surface-2); + color: var(--color-text); + border-bottom-left-radius: 2px; +} +.ki-msg.assistant .ki-msg-bubble strong { color: var(--color-ki); } + +.ki-typing-dot { + width: 6px; height: 6px; border-radius: 50%; + background: var(--color-text-muted); + animation: ki-typing 1.4s infinite ease-in-out; +} +.ki-typing-dot:nth-child(2) { animation-delay: 0.2s; } +.ki-typing-dot:nth-child(3) { animation-delay: 0.4s; } +@keyframes ki-typing { + 0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); } + 30% { opacity: 1; transform: scale(1); } +} +.ki-send-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.ki-input-wrap { + position: relative; + display: flex; + align-items: center; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + padding: 0 4px 0 10px; + transition: border-color var(--t-fast); +} +.ki-input-wrap:focus-within { + border-color: var(--color-ki); + box-shadow: 0 0 0 3px var(--color-ki-light); +} +.ki-input { + flex: 1; + height: 34px; + background: transparent; + border: none; + outline: none; + font-size: var(--fs-sm); + color: var(--color-text); +} +.ki-input::placeholder { color: var(--color-text-faint); } +.ki-voice-btn, .ki-send-btn { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + transition: all var(--t-fast); +} +.ki-voice-btn:hover { background: var(--color-surface-2); color: var(--color-ki); } +.ki-send-btn { background: var(--color-ki); color: white; } +.ki-send-btn:hover { background: var(--color-primary); } +.ki-voice-btn svg, .ki-send-btn svg { width: 14px; height: 14px; } + +/* ============================================================ + COMMAND LINE + STATUS BAR + ============================================================ */ +.cmdline { + background: var(--color-surface); + border-top: 1px solid var(--color-border); + display: flex; + align-items: stretch; + height: var(--cmdline-h); + font-family: var(--font-mono); + font-size: var(--fs-sm); + z-index: 20; + flex-shrink: 0; +} +.cmdline-history { + flex: 1; + padding: 6px 12px; + overflow-y: auto; + display: flex; + flex-direction: column-reverse; + border-right: 1px solid var(--color-border); + background: var(--color-surface-2); +} +.cmdline-history-entry { + display: flex; + gap: 8px; + padding: 1px 0; + line-height: 1.4; +} +.cmdline-history-entry .prefix { color: var(--color-primary); font-weight: 600; } +.cmdline-history-entry.info .prefix { color: var(--color-text-muted); } +.cmdline-history-entry.warn .prefix { color: var(--color-warning); } +.cmdline-history-entry.error .prefix { color: var(--color-error); } +.cmdline-history-entry .text { color: var(--color-text); } + +.cmdline-input-wrap { + display: flex; + align-items: center; + flex: 1.4; + padding: 0 12px; + gap: 8px; +} +.cmdline-prompt { color: var(--color-primary); font-weight: 700; font-size: var(--fs-md); } +.cmdline-input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-family: var(--font-mono); + font-size: var(--fs-sm); + color: var(--color-text); +} +.cmdline-input::placeholder { color: var(--color-text-faint); } + +.statusbar { + background: var(--color-surface-2); + border-top: 1px solid var(--color-border); + display: flex; + align-items: center; + height: var(--status-h); + padding: 0 var(--spacing-md); + font-size: var(--fs-xs); + color: var(--color-text-muted); + z-index: 20; + flex-shrink: 0; + overflow: hidden; +} +.status-item { + display: flex; + align-items: center; + gap: 4px; + padding: 0 10px; + height: 100%; + border-right: 1px solid var(--color-border); + transition: all var(--t-fast); + cursor: pointer; + white-space: nowrap; +} +.status-item:first-child { padding-left: 0; } +.status-item:last-child { border-right: none; margin-left: auto; padding-right: 0; } +.status-item:hover { background: var(--color-surface-3); color: var(--color-text); } +.status-item.active { color: var(--color-primary); } +.status-item svg { width: 12px; height: 12px; } +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-online); + display: inline-block; +} + +/* ============================================================ + RESPONSIVE: TABLET (≤1024px) — narrow sidebars, no labels + ============================================================ */ +@media (max-width: 1280px) { + :root { --rightbar-w: 280px; --leftbar-w: 180px; } + .ribbon-group-label { display: none; } + .prop-row { grid-template-columns: 80px 1fr; } +} + +@media (max-width: 1024px) { + :root { + --leftbar-w: 64px; + --rightbar-w: 240px; + } + /* Tools collapse to icon-only on tablet */ + .leftbar-title, .tool-section-label, .tool-btn-label { display: none; } + .tool-grid { grid-template-columns: 1fr 1fr; gap: 2px; } + .tool-btn { padding: 10px 4px; } + .leftbar-header { justify-content: center; padding: 6px; } + .leftbar-toggle { display: none; } + .ribbon-group-label { display: none; } + .canvas-view-tabs { display: none; } + .rightbar-tab span { display: none; } + .rightbar-tab { padding: 10px 2px; } + .rightbar-tab svg { width: 20px; height: 20px; } +} + +/* ============================================================ + RESPONSIVE: MOBILE (≤768px) — vertical right tab bar + drawers + ============================================================ */ +@media (max-width: 768px) { + :root { + --topbar-h: var(--mobile-topbar-h); + --ribbon-h: var(--mobile-ribbon-h); + --cmdline-h: 44px; + --status-h: 32px; + } + + /* Topbar: hamburger left, project center, notifications right */ + .hamburger-btn { display: flex; } + .app-name, .saved-badge, .lang-select, .topbar > .topbar-right > *:not(.hamburger-btn):not(.icon-btn-top[aria-label="Benachrichtigungen"]):not(.avatar) { + display: none; + } + .topbar { padding: 0 8px; gap: 6px; } + .topbar-left { flex: 1; min-width: 0; } + .project-name { padding: 4px 6px; min-width: 0; flex: 1; } + .project-name > span:not(.caret) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .project-name .caret { display: none; } + .icon-btn-top { width: 34px; height: 34px; } + + /* Ribbon: tab strip + scrollable icon buttons only */ + .ribbon-tab span { display: none; } + .ribbon-tab { padding: 0 12px; } + .ribbon-tab svg { width: 16px; height: 16px; } + .ribbon-content { + padding: 0 8px; + gap: 8px; + overflow-x: auto; + } + .ribbon-group { display: none; } + .ribbon-group.compact-mobile { + display: flex; + flex-direction: row; + align-items: center; + height: 100%; + padding: 0; + gap: 2px; + flex: 1; + min-width: 0; + } + .ribbon-group.compact-mobile .ribbon-group-btns { flex: 1; justify-content: space-around; } + .ribbon-divider { display: none; } + .ribbon-btn { min-width: 36px; } + .ribbon-btn span { display: none; } + + /* Main area: NO left/right desktop columns, canvas + right tab bar */ + .app-body { + grid-template-columns: 1fr var(--mobile-right-tab-w); + } + .leftbar, .rightbar { display: none; } + + /* Right tab bar (vertical icon bar, ALWAYS visible on mobile) */ + .right-tab-bar { + display: flex; + flex-direction: column; + background: var(--color-surface); + border-left: 1px solid var(--color-border); + z-index: 15; + } + .right-tab-btn { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 6px 2px; + color: var(--color-text-muted); + border-bottom: 1px solid var(--color-border); + border-left: 3px solid transparent; + transition: all var(--t-fast); + position: relative; + font-size: 9px; + font-weight: 500; + } + .right-tab-btn svg { width: 18px; height: 18px; } + .right-tab-btn:hover { background: var(--color-surface-2); color: var(--color-text); } + .right-tab-btn.active { + color: var(--color-primary); + background: var(--color-primary-50); + border-left-color: var(--color-primary); + } + [data-theme="dark"] .right-tab-btn.active { background: var(--color-surface-3); } + .right-tab-btn-badge { + position: absolute; + top: 4px; + right: 4px; + background: var(--color-ki); + color: white; + font-size: 9px; + font-weight: 700; + border-radius: 999px; + padding: 1px 4px; + line-height: 1.2; + } + .right-tab-btn:last-child { border-bottom: none; } + + /* Drawers (slide-in panels) */ + .drawer { + position: fixed; + top: 0; + bottom: 0; + width: var(--drawer-w); + max-width: 88vw; + background: var(--color-surface); + z-index: 100; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-drawer); + transition: transform var(--t-drawer); + will-change: transform; + } + .drawer-left { + left: 0; + transform: translateX(-100%); + border-right: 1px solid var(--color-border); + } + .drawer-right { + right: 0; + transform: translateX(100%); + border-left: 1px solid var(--color-border); + } + .drawer.open { transform: translateX(0); } + .drawer-header { + height: var(--mobile-topbar-h); + display: flex; + align-items: center; + padding: 0 12px; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-2); + gap: 8px; + flex-shrink: 0; + } + .drawer-title { + font-weight: 600; + font-size: var(--fs-md); + flex: 1; + color: var(--color-text); + } + .drawer-close { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + } + .drawer-close:hover { background: var(--color-surface-3); color: var(--color-text); } + .drawer-close svg { width: 18px; height: 18px; } + .drawer-body { + flex: 1; + overflow-y: auto; + padding: var(--spacing-md); + } + + /* Left drawer content: tool palette */ + .drawer-left .drawer-body { padding: var(--spacing-sm); } + .drawer-left .tool-section { padding: 8px; } + .drawer-left .tool-grid { grid-template-columns: repeat(4, 1fr); gap: 4px; } + .drawer-left .tool-btn { padding: 10px 4px; } + .drawer-left .tool-btn-kbd { display: none; } + .drawer-left .tool-btn-label { display: block; } + .drawer-left .tool-section-label { display: block; padding: 0 6px 6px; } + + /* Right drawer content: panel with mini tabs (Werkzeug/Layer/Bibliothek/KI) */ + .drawer-right .drawer-tabs { + display: flex; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-2); + flex-shrink: 0; + } + .drawer-right .drawer-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 10px 6px; + font-size: var(--fs-xs); + font-weight: 500; + color: var(--color-text-muted); + border-bottom: 2px solid transparent; + transition: all var(--t-fast); + } + .drawer-right .drawer-tab svg { width: 14px; height: 14px; } + .drawer-right .drawer-tab.active { + color: var(--color-primary); + background: var(--color-surface); + border-bottom-color: var(--color-primary); + } + .drawer-right .drawer-body { padding: var(--spacing-md); } + + /* Canvas: fill area, compact overlays */ + .canvas-coords { font-size: 10px; padding: 4px 8px; top: 8px; left: 8px; } + .canvas-toolbar { bottom: 8px; padding: 0 2px; gap: 0; } + .canvas-toolbar-btn { width: 28px; height: 28px; } + .canvas-toolbar-divider { margin: 0 2px; } + .canvas-zoom-display { font-size: 11px; min-width: 40px; padding: 0 4px; } + + /* Footer compact */ + .cmdline-history { display: none; } + .cmdline-input-wrap { padding: 0 8px; } + .cmdline-input { font-size: var(--fs-xs); } + .statusbar { font-size: 10px; padding: 0 4px; } + .status-item { padding: 0 6px; } + .status-item span:not(.status-dot) { display: none; } + .status-item .status-text-mobile { display: inline !important; } + .status-dot { display: inline-block; } + /* only show dot + Online indicator + a few key items */ + .status-item.hide-mobile { display: none; } + + /* SVG canvas: ensure it's scrollable/zoomable on touch */ + .canvas-svg { touch-action: pinch-zoom pan-x pan-y; } +} + +/* Very small screens (<480px) */ +@media (max-width: 480px) { + :root { --mobile-right-tab-w: 52px; } + .ribbon-content { padding: 0 4px; } + .right-tab-btn svg { width: 16px; height: 16px; } + .right-tab-btn { font-size: 8px; padding: 4px 2px; } + .topbar { padding: 0 4px; } + .statusbar { font-size: 9px; padding: 0 2px; } +} + +/* Body lock when drawer open */ +body.drawer-open { overflow: hidden; } + +/* ===================== TREE COMPONENT (Layer + Library) ===================== */ +.tree { padding: 4px 0; font-size: 12px; user-select: none; } +.tree-node { position: relative; } +.tree-row { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px 4px 4px; + border-radius: 4px; + cursor: pointer; + min-height: 26px; + color: var(--color-text); + transition: background-color .12s; +} +.tree-row:hover { background: var(--color-surface-2, rgba(0,0,0,0.04)); } +.tree-row.active { background: rgba(37, 99, 235, 0.10); color: var(--color-primary, #2563eb); font-weight: 500; } + +/* TreeView component actual classes */ +.tree-view { padding: 2px 0; } +.tree-node { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + min-height: 28px; + color: var(--color-text); + transition: background-color .12s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.tree-node:hover { background: var(--color-surface-2); } +.tree-node.active { background: var(--color-primary-50); color: var(--color-primary); font-weight: 500; } +[data-theme="dark"] .tree-node.active { background: var(--color-surface-3); } +.tree-toggle { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-text-muted); + background: none; + border: none; + cursor: pointer; + padding: 0; + border-radius: 3px; + transition: background-color .12s; +} +.tree-toggle:hover { background: var(--color-surface-3); color: var(--color-text); } +.tree-toggle svg { width: 12px; height: 12px; transition: transform .15s; } +.tree-toggle-placeholder { width: 16px; flex-shrink: 0; } +.tree-label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + color: var(--color-text); +} +.tree-node.active .tree-label { color: var(--color-primary); } +.tree-count { + font-size: 10px; + color: var(--color-text-muted); + background: var(--color-surface-2); + padding: 1px 6px; + border-radius: 8px; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} +.tree-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} +.tree-detail { + padding: 4px 8px 4px 24px; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-2); +} +.tree-children { + border-left: 1px dashed var(--color-border); + margin-left: 11px; +} + +/* BlockLibrary tree (distinct from TreeView) */ +.lib-tree-node { margin-bottom: 2px; } +.lib-tree-node .tree-item { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + min-height: 28px; + color: var(--color-text); + transition: background-color .12s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.lib-tree-node .tree-item:hover { background: var(--color-surface-2); } +.lib-tree-node .tree-item .tree-toggle { + width: 16px; + flex-shrink: 0; + color: var(--color-text-muted); + font-size: 12px; +} +.lib-tree-node .tree-item .tree-icon { font-size: 14px; flex-shrink: 0; } +.lib-tree-node .tree-item .tree-label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + color: var(--color-text); +} +.lib-tree-node .tree-item .tree-count { + font-size: 10px; + color: var(--color-text-muted); + background: var(--color-surface-2); + padding: 1px 6px; + border-radius: 8px; + flex-shrink: 0; +} +.lib-tree-node .tree-children { + padding-left: 20px; + border-left: 1px dashed var(--color-border); + margin-left: 11px; +} +.lib-tree-node .tree-item-leaf { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + cursor: grab; + min-height: 28px; + color: var(--color-text); + transition: background-color .12s; +} +.lib-tree-node .tree-item-leaf:hover { background: var(--color-surface-2); } +.lib-tree-node .tree-item-leaf .tree-icon { font-size: 14px; flex-shrink: 0; } +.lib-tree-node .tree-item-leaf .tree-label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + color: var(--color-text); +} +.lib-tree-node .tree-item-leaf .tree-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + opacity: 0; + transition: opacity .15s; +} +.lib-tree-node .tree-item-leaf:hover .tree-actions { opacity: 1; } + +.tree-toggle { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-text-muted); + background: none; + border: none; + cursor: pointer; + padding: 0; + border-radius: 3px; + transition: background-color .12s; +} +.tree-toggle:hover { background: rgba(0,0,0,0.08); color: var(--color-text); } +.tree-toggle svg { width: 12px; height: 12px; transition: transform .15s; } +.tree-toggle-leaf { visibility: hidden; cursor: default; } +.tree-toggle-leaf:hover { background: none; } + +.tree-node[aria-expanded="true"] > .tree-row > .tree-toggle svg { transform: rotate(0deg); } +.tree-node[aria-expanded="false"] > .tree-row > .tree-toggle svg { transform: rotate(-90deg); } + +.tree-icon { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; +} +.tree-icon svg { width: 14px; height: 14px; } + +.tree-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; +} + +.tree-count { + font-size: 10px; + color: var(--color-text-muted); + background: var(--color-surface-2, rgba(0,0,0,0.05)); + padding: 1px 6px; + border-radius: 8px; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.tree-children { padding-left: 16px; border-left: 1px dashed var(--color-border, #e2e8f0); margin-left: 11px; } +.tree-children[hidden] { display: none; } + +/* Library drag hint */ +.lib-drag-hint { + font-size: 14px; + color: var(--color-text-muted); + cursor: grab; + user-select: none; + flex-shrink: 0; + opacity: 0; + transition: opacity .15s; +} +.tree-row:hover .lib-drag-hint { opacity: 0.7; } +.lib-draggable.dragging { opacity: 0.4; } + +/* Tree scrollable container */ +.rightbar-panel .tree { max-height: calc(100vh - 360px); overflow-y: auto; padding-right: 4px; } + +/* Mobile drawer: same tree styling */ +.drawer-right .tree { padding: 8px 4px; font-size: 13px; } +.drawer-right .tree-name { font-size: 13px; } +.drawer-right .tree-row { min-height: 30px; padding: 6px 10px; } + +/* ─── Plugin Manager ──────────────────────────────────── */ +.plugin-manager { padding: 8px; } +.plugin-manager-header { + display: flex; justify-content: space-between; align-items: center; + padding: 4px 8px 12px; border-bottom: 1px solid var(--color-border); margin-bottom: 8px; +} +.plugin-card { + border: 1px solid var(--color-border); + border-radius: 6px; + margin-bottom: 6px; + overflow: hidden; + transition: border-color 0.2s; +} +.plugin-card.enabled { border-color: var(--color-primary); } +.plugin-card.expanded .plugin-card-body { display: block; } +.plugin-card-header { + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; cursor: pointer; user-select: none; +} +.plugin-icon { width: 20px; height: 20px; flex-shrink: 0; display: flex; align-items: center; } +.plugin-icon svg { width: 18px; height: 18px; } +.plugin-info { flex: 1; min-width: 0; } +.plugin-name { font-size: 13px; font-weight: 600; color: var(--color-text); } +.plugin-meta { font-size: 11px; color: var(--color-text-muted); margin-top: 2px; } +.plugin-card-body { + display: none; padding: 0 12px 12px; border-top: 1px solid var(--color-border); +} +.plugin-description { font-size: 12px; color: var(--color-text-muted); margin: 8px 0 4px; line-height: 1.4; } +.plugin-author { font-size: 11px; color: var(--color-text-muted); } +.plugin-toggle { + width: 36px; height: 20px; border-radius: 10px; + border: none; cursor: pointer; position: relative; + background: var(--color-border); transition: background 0.2s; flex-shrink: 0; +} +.plugin-toggle.on { background: var(--color-primary); } +.plugin-toggle-knob { + position: absolute; top: 2px; left: 2px; + width: 16px; height: 16px; border-radius: 50%; + background: white; transition: transform 0.2s; +} +.plugin-toggle.on .plugin-toggle-knob { transform: translateX(16px); } + +/* Export Menu */ +.export-menu-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; +} +.export-menu { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: 16px; + min-width: 280px; +} +.export-menu-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.export-menu-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-sm); + color: var(--color-text); + transition: background var(--t-fast); + text-align: left; +} +.export-menu-item:hover { background: var(--color-surface-2); } +.export-menu-format { + font-weight: 700; + font-size: 14px; + color: var(--color-primary); + min-width: 40px; +} +.export-menu-desc { + font-size: 12px; + color: var(--color-text-muted); +} + +/* ===================== DRAG & DROP INDICATORS ===================== */ +.tree-node.tree-drag-before { + border-top: 2px solid var(--color-primary, #2563eb); + margin-top: -1px; +} +.tree-node.tree-drag-after { + border-bottom: 2px solid var(--color-primary, #2563eb); + margin-bottom: -1px; +} +.tree-node.tree-drag-inside { + border: 2px solid var(--color-primary, #2563eb); + background: rgba(37, 99, 235, 0.08); + border-radius: 4px; +} +.tree-node.tree-dragging { + opacity: 0.4; +} + +/* ===================== SETTINGS MODAL ===================== */ +.settings-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 500; + display: flex; + align-items: center; + justify-content: center; +} +.settings-modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + max-width: 700px; + max-height: 80vh; + width: 90%; + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; +} +.settings-modal-close { + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: var(--color-text-muted); + width: 28px; + height: 28px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s, color 0.2s; + z-index: 1; +} +.settings-modal-close:hover { + background: var(--color-surface-2); + color: var(--color-text); +} +.settings-modal-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--color-border); + padding: 0 12px; + flex-shrink: 0; + overflow-x: auto; +} +.settings-modal-tab { + padding: 12px 16px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--color-text-muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: color 0.2s, border-color 0.2s; +} +.settings-modal-tab:hover { + color: var(--color-text); +} +.settings-modal-tab.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} +.settings-modal-content { + padding: 20px 24px; + overflow-y: auto; + flex: 1; +} +.settings-tab-content { + display: flex; + flex-direction: column; + gap: 6px; +} +.settings-label { + font-size: 12px; + font-weight: 600; + color: var(--color-text-muted); + margin-top: 10px; + margin-bottom: 2px; +} +.settings-label:first-child { + margin-top: 0; +} +.settings-input { + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-2); + color: var(--color-text); + font-size: 13px; + outline: none; + transition: border-color 0.2s; +} +.settings-input:focus { + border-color: var(--color-primary); +} +.settings-avatar-preview { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--color-primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 700; +} +.settings-message { + font-size: 12px; + color: var(--color-primary); + padding: 8px 0; +} +.settings-btn { + padding: 8px 16px; + border: none; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: white; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s; + margin-top: 12px; + align-self: flex-start; +} +.settings-btn:hover { + opacity: 0.85; +} +.settings-btn-danger { + padding: 6px 12px; + border: none; + border-radius: var(--radius-sm); + background: #e74c3c; + color: white; + font-size: 12px; + cursor: pointer; + transition: opacity 0.2s; +} +.settings-btn-danger:hover { + opacity: 0.85; +} +.settings-toggle-group { + display: flex; + gap: 8px; +} +.settings-toggle-btn { + padding: 8px 20px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-2); + color: var(--color-text-muted); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} +.settings-toggle-btn.active { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); +} +.settings-color-picker { + display: flex; + align-items: center; + gap: 10px; +} +.settings-color-picker input[type="color"] { + width: 40px; + height: 32px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + background: none; +} +.settings-color-value { + font-size: 13px; + color: var(--color-text-muted); + font-family: monospace; +} +.settings-users-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} +.settings-user-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.settings-user-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-2); +} +.settings-user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--color-primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + flex-shrink: 0; +} +.settings-user-info { + flex: 1; + min-width: 0; +} +.settings-user-name { + font-size: 13px; + font-weight: 600; + color: var(--color-text); +} +.settings-user-email { + font-size: 11px; + color: var(--color-text-muted); +} +.settings-user-role { + font-size: 11px; + font-weight: 600; + color: var(--color-primary); + background: rgba(37, 99, 235, 0.1); + padding: 3px 8px; + border-radius: 4px; +} diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css new file mode 100644 index 0000000..6755d5d --- /dev/null +++ b/frontend/src/styles/auth.css @@ -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); +} diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css deleted file mode 100644 index 1107b84..0000000 --- a/frontend/src/styles/theme.css +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/frontend/src/tools/.gitkeep b/frontend/src/tools/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/tools/drawing/.gitkeep b/frontend/src/tools/drawing/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/tools/modification/GroupTool.ts b/frontend/src/tools/modification/GroupTool.ts new file mode 100644 index 0000000..a218502 --- /dev/null +++ b/frontend/src/tools/modification/GroupTool.ts @@ -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 = 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 { + const ids = new Set(); + 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); + } + } +} diff --git a/frontend/src/tools/modification/geometry.ts b/frontend/src/tools/modification/geometry.ts new file mode 100644 index 0000000..96a3b9d --- /dev/null +++ b/frontend/src/tools/modification/geometry.ts @@ -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; +} diff --git a/frontend/src/types/cad.types.ts b/frontend/src/types/cad.types.ts new file mode 100644 index 0000000..ecafb06 --- /dev/null +++ b/frontend/src/types/cad.types.ts @@ -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'; diff --git a/frontend/src/types/ui.types.ts b/frontend/src/types/ui.types.ts new file mode 100644 index 0000000..01727f0 --- /dev/null +++ b/frontend/src/types/ui.types.ts @@ -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; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tests/Components.test.tsx b/frontend/tests/Components.test.tsx new file mode 100644 index 0000000..fe9da9a --- /dev/null +++ b/frontend/tests/Components.test.tsx @@ -0,0 +1,274 @@ +/** + * Component Tests – RibbonBar, Topbar, StatusBar, LayerPanel, PropertiesPanel + * Testet Rendering, Button-Klicks und Callback-Aufrufe. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import RibbonBar from '../src/components/RibbonBar'; +import Topbar from '../src/components/Topbar'; +import StatusBar from '../src/components/StatusBar'; +import LayerPanel from '../src/components/LayerPanel'; +import PropertiesPanel from '../src/components/PropertiesPanel'; +import type { CADElement, CADLayer } from '../src/types/cad.types'; + +// ─── Fixtures ──────────────────────────────────────── + +function makeLayer(overrides: Partial = {}): CADLayer { + return { + id: 'layer-1', + name: 'Layer 1', + visible: true, + locked: false, + color: '#ffffff', + lineType: 'solid', + transparency: 0, + sortOrder: 0, + parentId: null, + ...overrides, + }; +} + +function makeElement(overrides: Partial = {}): CADElement { + return { + id: 'elem-1', + type: 'rect', + layerId: 'layer-1', + x: 10, + y: 20, + width: 100, + height: 50, + properties: {}, + ...overrides, + }; +} + +// ─── RibbonBar ──────────────────────────────────────── + +describe('RibbonBar', () => { + it('should render all tab buttons', () => { + render( {}} onAction={() => {}} />); + // Tabs are in a tablist - use role=tab + const tabs = screen.getAllByRole('tab'); + expect(tabs.length).toBeGreaterThanOrEqual(3); + expect(screen.getByText('Start')).toBeInTheDocument(); + // 'Einfügen' appears as tab AND action - use getAllByText + const einfuegen = screen.getAllByText('Einfügen'); + expect(einfuegen.length).toBeGreaterThanOrEqual(1); + }); + + it('should call onTabChange when clicking a tab', () => { + const onTabChange = vi.fn(); + render( {}} />); + // Click the 'Format' tab (unique text) + fireEvent.click(screen.getByText('Format')); + expect(onTabChange).toHaveBeenCalledWith('format'); + }); + + it('should call onAction when clicking an action button', () => { + const onAction = vi.fn(); + render( {}} onAction={onAction} />); + // Find action buttons by class name + const actionBtns = document.querySelectorAll('.ribbon-btn'); + if (actionBtns.length > 0) { + fireEvent.click(actionBtns[0]); + expect(onAction).toHaveBeenCalled(); + } + }); +}); + +// ─── Topbar ─────────────────────────────────────────── + +describe('Topbar', () => { + it('should render project name and saved status', () => { + render( + {}} onRedo={() => {}} onThemeToggle={() => {}} theme="dark" />, + ); + expect(screen.getByText('Test Project')).toBeInTheDocument(); + expect(screen.getByText('Gespeichert')).toBeInTheDocument(); + }); + + it('should call onUndo when clicking undo button', () => { + const onUndo = vi.fn(); + render( + {}} onThemeToggle={() => {}} theme="dark" />, + ); + fireEvent.click(screen.getByLabelText(/Rückgängig/i)); + expect(onUndo).toHaveBeenCalled(); + }); + + it('should call onRedo when clicking redo button', () => { + const onRedo = vi.fn(); + render( + {}} onRedo={onRedo} onThemeToggle={() => {}} theme="dark" />, + ); + fireEvent.click(screen.getByLabelText(/Wiederherstellen/i)); + expect(onRedo).toHaveBeenCalled(); + }); + + it('should call onThemeToggle when clicking theme button', () => { + const onThemeToggle = vi.fn(); + render( + {}} onRedo={() => {}} onThemeToggle={onThemeToggle} theme="dark" />, + ); + fireEvent.click(screen.getByLabelText(/Hell.*Dunkel|Theme/i)); + expect(onThemeToggle).toHaveBeenCalled(); + }); +}); + +// ─── StatusBar ──────────────────────────────────────── + +describe('StatusBar', () => { + const defaultProps = { + snapEnabled: true, + orthoEnabled: false, + polarEnabled: true, + gridEnabled: false, + cursorX: 123.456, + cursorY: 78.9, + activeLayer: 'Layer 1', + activeTool: 'line', + onlineCount: 3, + onToggleSnap: vi.fn(), + onToggleOrtho: vi.fn(), + onTogglePolar: vi.fn(), + onToggleGrid: vi.fn(), + }; + + it('should render cursor coordinates', () => { + render(); + expect(screen.getByText(/123/)).toBeInTheDocument(); + expect(screen.getByText(/78/)).toBeInTheDocument(); + }); + + it('should render active layer and tool', () => { + render(); + expect(screen.getByText('Layer 1')).toBeInTheDocument(); + expect(screen.getByText('line')).toBeInTheDocument(); + }); + + it('should render online count', () => { + render(); + // Online count is in a div with title containing 'online' + const onlineEl = screen.getByTitle(/online/i); + expect(onlineEl).toBeInTheDocument(); + expect(onlineEl.textContent).toContain('3'); + }); + + it('should call onToggleSnap when clicking snap toggle', () => { + const onToggleSnap = vi.fn(); + render(); + // StatusBar uses div with title attribute, not button with aria-label + fireEvent.click(screen.getByTitle(/Snap-Modus/i)); + expect(onToggleSnap).toHaveBeenCalled(); + }); + + it('should call onToggleOrtho when clicking ortho toggle', () => { + const onToggleOrtho = vi.fn(); + render(); + fireEvent.click(screen.getByTitle(/Ortho-Modus/i)); + expect(onToggleOrtho).toHaveBeenCalled(); + }); +}); + +// ─── LayerPanel ────────────────────────────────────── + +describe('LayerPanel', () => { + const layers = [ + makeLayer({ id: 'layer-1', name: 'Wände' }), + makeLayer({ id: 'layer-2', name: 'Türen', visible: false }), + ]; + + it('should render layer names', () => { + render( + {}} + onAddLayer={() => {}} + onToggleLayer={() => {}} + />, + ); + expect(screen.getByText('Wände')).toBeInTheDocument(); + expect(screen.getByText('Türen')).toBeInTheDocument(); + }); + + it('should call onAddLayer when clicking add button', () => { + const onAddLayer = vi.fn(); + render( + {}} + onAddLayer={onAddLayer} + onToggleLayer={() => {}} + />, + ); + // Add button has class 'add-layer-btn' + const addBtn = document.querySelector('.add-layer-btn'); + expect(addBtn).toBeTruthy(); + fireEvent.click(addBtn!); + expect(onAddLayer).toHaveBeenCalled(); + }); + + it('should call onSelectLayer when clicking a layer', () => { + const onSelectLayer = vi.fn(); + render( + {}} + onToggleLayer={() => {}} + />, + ); + fireEvent.click(screen.getByText('Wände')); + expect(onSelectLayer).toHaveBeenCalledWith('layer-1'); + }); + + it('should call onToggleLayer when toggling visibility', () => { + const onToggleLayer = vi.fn(); + render( + {}} + onAddLayer={() => {}} + onToggleLayer={onToggleLayer} + />, + ); + // Visibility toggle has aria-label 'Verstecken' (visible) or 'Anzeigen' (hidden) + const toggleBtn = screen.getByLabelText('Verstecken'); + fireEvent.click(toggleBtn); + expect(onToggleLayer).toHaveBeenCalled(); + }); +}); + +// ─── PropertiesPanel ───────────────────────────────── + +describe('PropertiesPanel', () => { + const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })]; + const element = makeElement({ id: 'elem-1', type: 'rect', x: 10, y: 20, width: 100, height: 50 }); + + it('should show default values when no element selected', () => { + render( {}} />); + // Default values are in inputs with aria-labels + expect(screen.getByLabelText('Position X')).toHaveDisplayValue('0.000 m'); + expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('0.000 m'); + }); + + it('should display element coordinates when element is selected', () => { + render( {}} />); + expect(screen.getByLabelText('Position X')).toHaveDisplayValue('10.000 m'); + expect(screen.getByLabelText('Position Y')).toHaveDisplayValue('20.000 m'); + }); + + it('should display element dimensions', () => { + render( {}} />); + expect(screen.getByLabelText('Breite')).toHaveDisplayValue('100.00 m'); + expect(screen.getByLabelText('Tiefe')).toHaveDisplayValue('50.00 m'); + }); + + it('should call onUpdateProperty when changing a property input', () => { + const onUpdateProperty = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText('Position X'), { target: { value: '999' } }); + expect(onUpdateProperty).toHaveBeenCalledWith('x', '999'); + }); +}); diff --git a/frontend/tests/GroupTool.test.ts b/frontend/tests/GroupTool.test.ts new file mode 100644 index 0000000..4fa8893 --- /dev/null +++ b/frontend/tests/GroupTool.test.ts @@ -0,0 +1,190 @@ +/** + * GroupTool Tests – GroupManager: create, ungroup, nest, query + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { GroupManager } from '../src/tools/modification/GroupTool'; +import type { ElementGroup } from '../src/tools/modification/GroupTool'; + +describe('GroupManager', () => { + let gm: GroupManager; + + beforeEach(() => { + gm = new GroupManager(); + }); + + describe('createGroup', () => { + it('should create a group with element IDs', () => { + const group = gm.createGroup(['el-1', 'el-2', 'el-3']); + expect(group.id).toBeDefined(); + expect(group.elementIds).toEqual(['el-1', 'el-2', 'el-3']); + expect(group.parentGroupId).toBeNull(); + }); + + it('should create a group with a custom name', () => { + const group = gm.createGroup(['el-1'], 'My Group'); + expect(group.name).toBe('My Group'); + }); + + it('should create a group with default name when no name provided', () => { + const group = gm.createGroup(['el-1']); + expect(group.name).toContain('Group'); + }); + + it('should create multiple groups with unique IDs', () => { + const g1 = gm.createGroup(['el-1']); + const g2 = gm.createGroup(['el-2']); + expect(g1.id).not.toBe(g2.id); + }); + }); + + describe('ungroup', () => { + it('should remove a group and return its element IDs', () => { + const group = gm.createGroup(['el-1', 'el-2']); + const ids = gm.ungroup(group.id); + expect(ids).toEqual(['el-1', 'el-2']); + expect(gm.getGroup(group.id)).toBeUndefined(); + }); + + it('should return empty array for non-existent group', () => { + const ids = gm.ungroup('non-existent'); + expect(ids).toEqual([]); + }); + }); + + describe('getGroup', () => { + it('should retrieve a group by ID', () => { + const group = gm.createGroup(['el-1']); + const retrieved = gm.getGroup(group.id); + expect(retrieved).toBeDefined(); + expect(retrieved!.id).toBe(group.id); + }); + + it('should return undefined for non-existent group', () => { + expect(gm.getGroup('non-existent')).toBeUndefined(); + }); + }); + + describe('getGroups', () => { + it('should return all groups', () => { + gm.createGroup(['el-1']); + gm.createGroup(['el-2']); + expect(gm.getGroups().length).toBe(2); + }); + + it('should return empty array when no groups', () => { + expect(gm.getGroups()).toEqual([]); + }); + }); + + describe('getGroupedElements', () => { + it('should return set of all element IDs in all groups', () => { + gm.createGroup(['el-1', 'el-2']); + gm.createGroup(['el-3']); + const ids = gm.getGroupedElements(); + expect(ids.size).toBe(3); + expect(ids.has('el-1')).toBe(true); + expect(ids.has('el-2')).toBe(true); + expect(ids.has('el-3')).toBe(true); + }); + + it('should return empty set when no groups', () => { + expect(gm.getGroupedElements().size).toBe(0); + }); + }); + + describe('getGroupForElement', () => { + it('should return group ID for an element in a group', () => { + const group = gm.createGroup(['el-1', 'el-2']); + expect(gm.getGroupForElement('el-1')).toBe(group.id); + expect(gm.getGroupForElement('el-2')).toBe(group.id); + }); + + it('should return null for element not in any group', () => { + expect(gm.getGroupForElement('el-lonely')).toBeNull(); + }); + }); + + describe('moveGroup', () => { + it('should return element IDs that need to be moved', () => { + const group = gm.createGroup(['el-1', 'el-2']); + const ids = gm.moveGroup(group.id, 10, 20); + expect(ids).toEqual(['el-1', 'el-2']); + }); + + it('should return empty array for non-existent group', () => { + const ids = gm.moveGroup('non-existent', 10, 20); + expect(ids).toEqual([]); + }); + }); + + describe('setParent (nested groups)', () => { + it('should set parent group for nesting', () => { + const parent = gm.createGroup(['el-1']); + const child = gm.createGroup(['el-2']); + gm.setParent(child.id, parent.id); + expect(gm.getGroup(child.id)!.parentGroupId).toBe(parent.id); + }); + + it('should prevent circular references', () => { + const g1 = gm.createGroup(['el-1']); + const g2 = gm.createGroup(['el-2']); + gm.setParent(g1.id, g2.id); + // Trying to set g2's parent to g1 would create a cycle: g1 -> g2 -> g1 + gm.setParent(g2.id, g1.id); + expect(gm.getGroup(g2.id)!.parentGroupId).toBeNull(); + }); + + it('should allow setting parent to null', () => { + const parent = gm.createGroup(['el-1']); + const child = gm.createGroup(['el-2']); + gm.setParent(child.id, parent.id); + gm.setParent(child.id, null); + expect(gm.getGroup(child.id)!.parentGroupId).toBeNull(); + }); + + it('should do nothing for non-existent group', () => { + gm.setParent('non-existent', null); + expect(gm.getGroup('non-existent')).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('should clear all groups', () => { + gm.createGroup(['el-1']); + gm.createGroup(['el-2']); + gm.clear(); + expect(gm.getGroups().length).toBe(0); + }); + }); + + describe('toJSON & fromJSON', () => { + it('should serialize groups to JSON', () => { + gm.createGroup(['el-1', 'el-2'], 'Group A'); + gm.createGroup(['el-3'], 'Group B'); + const json = gm.toJSON(); + expect(json.length).toBe(2); + expect(json[0].name).toBe('Group A'); + expect(json[1].name).toBe('Group B'); + }); + + it('should restore groups from JSON', () => { + const groups: ElementGroup[] = [ + { id: 'grp-1', name: 'Restored A', elementIds: ['el-1'], parentGroupId: null }, + { id: 'grp-2', name: 'Restored B', elementIds: ['el-2', 'el-3'], parentGroupId: 'grp-1' }, + ]; + gm.fromJSON(groups); + expect(gm.getGroups().length).toBe(2); + expect(gm.getGroup('grp-1')!.name).toBe('Restored A'); + expect(gm.getGroup('grp-2')!.parentGroupId).toBe('grp-1'); + }); + + it('should clear existing groups when restoring from JSON', () => { + gm.createGroup(['el-old']); + gm.fromJSON([ + { id: 'grp-new', name: 'New', elementIds: ['el-new'], parentGroupId: null }, + ]); + expect(gm.getGroups().length).toBe(1); + expect(gm.getGroup('grp-new')).toBeDefined(); + }); + }); +}); diff --git a/frontend/tests/HistoryManager.test.ts b/frontend/tests/HistoryManager.test.ts new file mode 100644 index 0000000..1d03df4 --- /dev/null +++ b/frontend/tests/HistoryManager.test.ts @@ -0,0 +1,388 @@ +/** + * HistoryManager Tests – Undo/Redo + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { HistoryManager } from '../src/history/HistoryManager'; +import type { CADStateSnapshot } from '../src/history/HistoryManager'; +import type { CADElement, CADLayer, BlockDefinition } from '../src/types/cad.types'; +import type { ElementGroup } from '../src/tools/modification/GroupTool'; +import type { BackgroundConfig } from '../src/services/backgroundService'; + +function makeSnapshot(label: string, suffix: string = ''): Omit { + const elements: CADElement[] = [ + { id: `el-${suffix}-1`, type: 'rect', layerId: 'layer-1', x: 0, y: 0, width: 10, height: 10, properties: {} }, + { id: `el-${suffix}-2`, type: 'circle', layerId: 'layer-1', x: 50, y: 50, width: 20, height: 20, properties: {} }, + ]; + const layers: CADLayer[] = [ + { id: 'layer-1', name: 'Layer 1', visible: true, locked: false, color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null }, + ]; + const blocks: BlockDefinition[] = []; + const groups: ElementGroup[] = []; + const bgConfig: BackgroundConfig | null = null; + return { elements, layers, blocks, groups, bgConfig }; +} + +describe('HistoryManager', () => { + let hm: HistoryManager; + + beforeEach(() => { + hm = new HistoryManager(); + }); + + describe('initialize', () => { + it('should set initial state without undo history', () => { + const snap = makeSnapshot('Initial'); + hm.initialize(snap); + expect(hm.getCurrentState()).not.toBeNull(); + expect(hm.getCurrentState()?.label).toBe('Initial'); + expect(hm.canUndo()).toBe(false); + expect(hm.canRedo()).toBe(false); + }); + + it('should reset undo and redo stacks on initialize', () => { + const snap = makeSnapshot('Initial'); + hm.initialize(snap); + hm.pushSnapshot(makeSnapshot('Change 1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2'), 'Change 2'); + + // Re-initialize should clear history + hm.initialize(snap); + expect(hm.canUndo()).toBe(false); + expect(hm.canRedo()).toBe(false); + expect(hm.getUndoCount()).toBe(0); + expect(hm.getRedoCount()).toBe(0); + }); + }); + + describe('pushSnapshot', () => { + it('should push current state to undo stack and set new state', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + + expect(hm.getCurrentState()?.label).toBe('Change 1'); + expect(hm.canUndo()).toBe(true); + expect(hm.getUndoCount()).toBe(1); + }); + + it('should clear redo stack on new push', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); // Now redo stack has 1 entry + expect(hm.canRedo()).toBe(true); + + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + expect(hm.canRedo()).toBe(false); + expect(hm.getRedoCount()).toBe(0); + }); + + it('should store multiple snapshots', () => { + hm.initialize(makeSnapshot('Initial')); + for (let i = 1; i <= 5; i++) { + hm.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`); + } + expect(hm.getUndoCount()).toBe(5); + expect(hm.getCurrentState()?.label).toBe('Change 5'); + }); + }); + + describe('undo', () => { + it('should restore previous state', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + + const prev = hm.undo(); + expect(prev).not.toBeNull(); + expect(prev?.label).toBe('Initial'); + expect(hm.getCurrentState()?.label).toBe('Initial'); + }); + + it('should return null when no undo available', () => { + hm.initialize(makeSnapshot('Initial')); + const result = hm.undo(); + expect(result).toBeNull(); + }); + + it('should move current state to redo stack', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); + + expect(hm.canRedo()).toBe(true); + expect(hm.getRedoCount()).toBe(1); + }); + + it('should handle multiple undos', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3'); + + hm.undo(); + expect(hm.getCurrentState()?.label).toBe('Change 2'); + hm.undo(); + expect(hm.getCurrentState()?.label).toBe('Change 1'); + hm.undo(); + expect(hm.getCurrentState()?.label).toBe('Initial'); + expect(hm.canUndo()).toBe(false); + }); + }); + + describe('redo', () => { + it('should restore next state after undo', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); + + const next = hm.redo(); + expect(next).not.toBeNull(); + expect(next?.label).toBe('Change 1'); + expect(hm.getCurrentState()?.label).toBe('Change 1'); + }); + + it('should return null when no redo available', () => { + hm.initialize(makeSnapshot('Initial')); + const result = hm.redo(); + expect(result).toBeNull(); + }); + + it('should move current state back to undo stack', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); + hm.redo(); + + expect(hm.canUndo()).toBe(true); + expect(hm.getUndoCount()).toBe(1); + }); + + it('should handle multiple redos', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3'); + + // Undo all 3 + hm.undo(); hm.undo(); hm.undo(); + expect(hm.getCurrentState()?.label).toBe('Initial'); + + // Redo all 3 + hm.redo(); + expect(hm.getCurrentState()?.label).toBe('Change 1'); + hm.redo(); + expect(hm.getCurrentState()?.label).toBe('Change 2'); + hm.redo(); + expect(hm.getCurrentState()?.label).toBe('Change 3'); + expect(hm.canRedo()).toBe(false); + }); + }); + + describe('canUndo & canRedo', () => { + it('canUndo should be false initially', () => { + hm.initialize(makeSnapshot('Initial')); + expect(hm.canUndo()).toBe(false); + }); + + it('canUndo should be true after push', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + expect(hm.canUndo()).toBe(true); + }); + + it('canRedo should be false initially', () => { + hm.initialize(makeSnapshot('Initial')); + expect(hm.canRedo()).toBe(false); + }); + + it('canRedo should be true after undo', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); + expect(hm.canRedo()).toBe(true); + }); + }); + + describe('getHistory', () => { + it('should return history entries with current marked', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + + const history = hm.getHistory(); + expect(history.length).toBe(3); // 2 undo + 1 current + const currentEntries = history.filter(e => e.isCurrent); + expect(currentEntries.length).toBe(1); + expect(currentEntries[0].label).toBe('Change 2'); + }); + + it('should include redo entries after undo', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.undo(); + + const history = hm.getHistory(); + // 0 undo + 1 current (Initial) + 1 redo (Change 1) + expect(history.length).toBe(2); + const currentEntries = history.filter(e => e.isCurrent); + expect(currentEntries.length).toBe(1); + expect(currentEntries[0].label).toBe('Initial'); + }); + + it('should return empty-ish history for fresh manager', () => { + const history = hm.getHistory(); + expect(history.length).toBe(0); + }); + }); + + describe('jumpTo', () => { + it('should jump to a specific undo entry', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3'); + + // Jump to undo-0 (Initial state) + const result = hm.jumpTo('undo-0'); + expect(result).not.toBeNull(); + expect(hm.getCurrentState()?.label).toBe('Initial'); + }); + + it('should jump to current', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + + const result = hm.jumpTo('current'); + expect(result).not.toBeNull(); + expect(result?.label).toBe('Change 1'); + }); + + it('should jump to a redo entry', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + hm.undo(); + hm.undo(); + + // Jump to redo-1 (Change 2) + const result = hm.jumpTo('redo-1'); + expect(result).not.toBeNull(); + expect(hm.getCurrentState()?.label).toBe('Change 2'); + }); + + it('should return null for unknown entry id', () => { + hm.initialize(makeSnapshot('Initial')); + const result = hm.jumpTo('unknown-id'); + expect(result).toBeNull(); + }); + }); + + describe('clear', () => { + it('should clear all history', () => { + hm.initialize(makeSnapshot('Initial')); + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + + hm.clear(); + expect(hm.getCurrentState()).toBeNull(); + expect(hm.canUndo()).toBe(false); + expect(hm.canRedo()).toBe(false); + expect(hm.getUndoCount()).toBe(0); + expect(hm.getRedoCount()).toBe(0); + }); + }); + + describe('subscribe', () => { + it('should notify listeners on state change', () => { + let callCount = 0; + const unsubscribe = hm.subscribe(() => callCount++); + + hm.initialize(makeSnapshot('Initial')); + expect(callCount).toBe(1); + + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + expect(callCount).toBe(2); + + hm.undo(); + expect(callCount).toBe(3); + + unsubscribe(); + hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); + expect(callCount).toBe(3); // No new calls after unsubscribe + }); + + it('should notify on clear', () => { + let callCount = 0; + hm.subscribe(() => callCount++); + hm.initialize(makeSnapshot('Initial')); + hm.clear(); + expect(callCount).toBe(2); // init + clear + }); + }); + + describe('maxStackSize', () => { + it('should limit undo stack size', () => { + const smallHM = new HistoryManager({ maxStackSize: 3 }); + smallHM.initialize(makeSnapshot('Initial')); + + for (let i = 1; i <= 10; i++) { + smallHM.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`); + } + + expect(smallHM.getUndoCount()).toBe(3); + }); + + it('should use default max size of 100', () => { + const defaultHM = new HistoryManager(); + defaultHM.initialize(makeSnapshot('Initial')); + + for (let i = 1; i <= 150; i++) { + defaultHM.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`); + } + + expect(defaultHM.getUndoCount()).toBe(100); + }); + }); + + describe('getUndoCount & getRedoCount', () => { + it('should track counts correctly', () => { + hm.initialize(makeSnapshot('Initial')); + expect(hm.getUndoCount()).toBe(0); + expect(hm.getRedoCount()).toBe(0); + + hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); + expect(hm.getUndoCount()).toBe(1); + expect(hm.getRedoCount()).toBe(0); + + hm.undo(); + expect(hm.getUndoCount()).toBe(0); + expect(hm.getRedoCount()).toBe(1); + + hm.redo(); + expect(hm.getUndoCount()).toBe(1); + expect(hm.getRedoCount()).toBe(0); + }); + }); + + describe('snapshot data integrity', () => { + it('should preserve elements in snapshots', () => { + const snap = makeSnapshot('Test'); + hm.initialize(snap); + const current = hm.getCurrentState(); + expect(current?.elements.length).toBe(2); + expect(current?.elements[0].id).toBe('el--1'); + }); + + it('should preserve different element sets across snapshots', () => { + hm.initialize(makeSnapshot('Initial')); + const snap1 = makeSnapshot('Change 1', 'v1'); + hm.pushSnapshot(snap1, 'Change 1'); + + hm.undo(); + const afterUndo = hm.getCurrentState(); + expect(afterUndo?.elements[0].id).toBe('el--1'); // Initial state + + hm.redo(); + const afterRedo = hm.getCurrentState(); + expect(afterRedo?.elements[0].id).toBe('el-v1-1'); // Change 1 state + }); + }); +}); diff --git a/frontend/tests/IntegrationWorkflow.test.ts b/frontend/tests/IntegrationWorkflow.test.ts new file mode 100644 index 0000000..b88c38b --- /dev/null +++ b/frontend/tests/IntegrationWorkflow.test.ts @@ -0,0 +1,911 @@ +/** + * Integration Workflow Test — Full CAD workflow across ALL components. + * + * Tests the complete lifecycle: init → layers → elements → render → zoom/pan → + * selection → grouping → snap → history → layer toggle → zoom fit → reset. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { RenderEngine } from '../src/canvas/RenderEngine'; +import { SelectionEngine } from '../src/canvas/SelectionEngine'; +import { ZoomPanController } from '../src/canvas/ZoomPanController'; +import { SpatialIndex } from '../src/canvas/SpatialIndex'; +import { LayerManager } from '../src/canvas/LayerManager'; +import { SnapEngine } from '../src/canvas/SnapEngine'; +import { HistoryManager } from '../src/history/HistoryManager'; +import { GroupManager } from '../src/tools/modification/GroupTool'; +import { CommandRegistry } from '../src/services/commandRegistry'; +import type { CADElement, CADLayer, BlockDefinition } from '../src/types/cad.types'; +import type { BackgroundConfig } from '../src/services/backgroundService'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function createMockCanvas(w = 800, h = 600): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + return canvas; +} + +function makeLayer(id: string, overrides: Partial = {}): CADLayer { + return { + id, + name: id, + visible: true, + locked: false, + color: '#ffffff', + lineType: 'solid' as const, + transparency: 0, + sortOrder: 0, + parentId: null, + ...overrides, + }; +} + +function makeLine( + id: string, + x1: number, y1: number, + x2: number, y2: number, + layerId = 'layer-default', +): CADElement { + return { + id, + 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, stroke: '#ffffff' }, + }; +} + +function makeRect( + id: string, + cx: number, cy: number, + w: number, h: number, + layerId = 'layer-default', +): CADElement { + return { + id, + type: 'rect', + layerId, + x: cx, + y: cy, + width: w, + height: h, + properties: { stroke: '#ffffff' }, + }; +} + +function makeCircle( + id: string, + cx: number, cy: number, + r: number, + layerId = 'layer-default', +): CADElement { + return { + id, + type: 'circle', + layerId, + x: cx, + y: cy, + width: r * 2, + height: r * 2, + properties: { radius: r, stroke: '#ffffff' }, + }; +} + +function makeSnapshot( + elements: CADElement[], + layers: CADLayer[], + blocks: BlockDefinition[] = [], + groups: ReturnType = [], + bgConfig: BackgroundConfig | null = null, +) { + return { elements, layers, blocks, groups, bgConfig }; +} + +// ─── Integration Test ───────────────────────────────────────────────────────── + +describe('Integration Workflow — Full CAD Lifecycle', () => { + // Component instances + let canvas: HTMLCanvasElement; + let layerManager: LayerManager; + let spatialIndex: SpatialIndex; + let zoomPan: ZoomPanController; + let renderEngine: RenderEngine; + let selectionEngine: SelectionEngine; + let snapEngine: SnapEngine; + let historyManager: HistoryManager; + let groupManager: GroupManager; + let commandRegistry: CommandRegistry; + + // Shared state + let layers: CADLayer[]; + let elements: CADElement[]; + + beforeEach(() => { + canvas = createMockCanvas(800, 600); + + // Step 1: Initialize all components + layerManager = new LayerManager(); + spatialIndex = new SpatialIndex(); + zoomPan = new ZoomPanController(canvas); + renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager); + selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager); + snapEngine = new SnapEngine({ tolerance: 10 }); + historyManager = new HistoryManager(); + groupManager = new GroupManager(); + commandRegistry = new CommandRegistry(); + + elements = []; + layers = []; + }); + + // ─── Step 1: Initialize all components ────────────────────────────────────── + describe('Step 1: Component initialization', () => { + it('should instantiate all components without errors', () => { + expect(layerManager).toBeDefined(); + expect(spatialIndex).toBeDefined(); + expect(zoomPan).toBeDefined(); + expect(renderEngine).toBeDefined(); + expect(selectionEngine).toBeDefined(); + expect(snapEngine).toBeDefined(); + expect(historyManager).toBeDefined(); + expect(groupManager).toBeDefined(); + expect(commandRegistry).toBeDefined(); + }); + + it('should have correct initial state', () => { + expect(layerManager.getLayers()).toHaveLength(0); + expect(layerManager.getActiveLayerId()).toBe(''); + expect(zoomPan.getScale()).toBe(1); + expect(selectionEngine.getSelectedIds().size).toBe(0); + expect(groupManager.getGroups()).toHaveLength(0); + expect(historyManager.getCurrentState()).toBeNull(); + expect(historyManager.canUndo()).toBe(false); + expect(historyManager.canRedo()).toBe(false); + }); + }); + + // ─── Step 2: Create layers, set visibility/lock ───────────────────────────── + describe('Step 2: Layer management', () => { + it('should create default and additional layers, set visibility/lock', () => { + const defaultLayer = makeLayer('layer-default', { name: 'Default', sortOrder: 0 }); + const archLayer = makeLayer('layer-arch', { name: 'Architecture', sortOrder: 1, color: '#ff0000' }); + const hiddenLayer = makeLayer('layer-hidden', { name: 'Hidden', sortOrder: 2, visible: false }); + const lockedLayer = makeLayer('layer-locked', { name: 'Locked', sortOrder: 3, locked: true }); + + layerManager.addLayer(defaultLayer); + layerManager.addLayer(archLayer); + layerManager.addLayer(hiddenLayer); + layerManager.addLayer(lockedLayer); + + layers = layerManager.getLayers(); + expect(layers).toHaveLength(4); + expect(layers[0].id).toBe('layer-default'); // sortOrder 0 + expect(layers[3].id).toBe('layer-locked'); // sortOrder 3 + + // Active layer should be first added + expect(layerManager.getActiveLayerId()).toBe('layer-default'); + + // Set active layer + layerManager.setActiveLayer('layer-arch'); + expect(layerManager.getActiveLayerId()).toBe('layer-arch'); + expect(layerManager.getActiveLayer()?.name).toBe('Architecture'); + + // Toggle visibility on arch layer + layerManager.toggleVisibility('layer-arch'); + expect(layerManager.getLayer('layer-arch')?.visible).toBe(false); + + // Toggle back + layerManager.toggleVisibility('layer-arch'); + expect(layerManager.getLayer('layer-arch')?.visible).toBe(true); + + // Toggle lock on default layer + layerManager.toggleLock('layer-default'); + expect(layerManager.isLocked('layer-default')).toBe(true); + + // getVisibleLayers returns visible && !locked + const visible = layerManager.getVisibleLayers(); + const visibleIds = visible.map(l => l.id); + // layer-default: visible=true but locked=true → excluded + // layer-arch: visible=true, locked=false → included + // layer-hidden: visible=false → excluded + // layer-locked: visible=true but locked=true → excluded + expect(visibleIds).toContain('layer-arch'); + expect(visibleIds).not.toContain('layer-default'); + expect(visibleIds).not.toContain('layer-hidden'); + expect(visibleIds).not.toContain('layer-locked'); + + // Unlock default for subsequent tests + layerManager.toggleLock('layer-default'); + expect(layerManager.isLocked('layer-default')).toBe(false); + }); + }); + + // ─── Step 3: Add elements to different layers, insert into SpatialIndex ────── + describe('Step 3: Add elements to layers and SpatialIndex', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1, color: '#ff0000' })); + }); + + it('should add line, rect, circle to different layers and insert into SpatialIndex', () => { + const line1 = makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'); + const rect1 = makeRect('el-rect-1', 50, 50, 80, 60, 'layer-arch'); + const circle1 = makeCircle('el-circle-1', 200, 200, 40, 'layer-default'); + + elements = [line1, rect1, circle1]; + + // Insert into spatial index + for (const el of elements) { + spatialIndex.insert(el); + } + + // Verify spatial index search returns elements in viewport + const viewport = { minX: -100, minY: -100, maxX: 300, maxY: 300 }; + const found = spatialIndex.search(viewport); + expect(found).toHaveLength(3); + expect(found.map(e => e.id).sort()).toEqual(['el-circle-1', 'el-line-1', 'el-rect-1']); + + // Verify search with smaller viewport + const smallVp = { minX: -10, minY: -10, maxX: 60, maxY: 60 }; + const smallFound = spatialIndex.search(smallVp); + // line1 bbox: minX=-50, minY=-50, maxX=150, maxY=150 → intersects + // rect1 bbox: minX=10, minY=20, maxX=90, maxY=80 → intersects + // circle1 bbox: minX=160, minY=160, maxX=240, maxY=240 → no + expect(smallFound.map(e => e.id).sort()).toEqual(['el-line-1', 'el-rect-1']); + }); + }); + + // ─── Step 4: Render (verify no crash) ─────────────────────────────────────── + describe('Step 4: Rendering', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1, color: '#ff0000' })); + + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeRect('el-rect-1', 50, 50, 80, 60, 'layer-arch'), + makeCircle('el-circle-1', 200, 200, 40, 'layer-default'), + ]; + for (const el of elements) spatialIndex.insert(el); + }); + + it('should render without crashing', () => { + expect(() => renderEngine.render()).not.toThrow(); + }); + + it('should render with grid enabled', () => { + renderEngine.setOptions({ showGrid: true }); + expect(() => renderEngine.render()).not.toThrow(); + }); + + it('should render with grid disabled', () => { + renderEngine.setOptions({ showGrid: false }); + expect(() => renderEngine.render()).not.toThrow(); + }); + }); + + // ─── Step 5: Zoom/Pan ──────────────────────────────────────────────────────── + describe('Step 5: Zoom and Pan', () => { + it('should zoomAt, pan, and verify worldToScreen/screenToWorld roundtrip', () => { + // Initial state + expect(zoomPan.getScale()).toBe(1); + const initialViewport = zoomPan.getViewport(); + expect(initialViewport.minX).toBe(0); + expect(initialViewport.minY).toBe(0); + + // Zoom in at center (400, 300) with factor 2 + zoomPan.zoomAt(400, 300, 2); + expect(zoomPan.getScale()).toBe(2); + + // After zoom, viewport should be smaller in world space + const zoomedViewport = zoomPan.getViewport(); + expect(zoomedViewport.maxX - zoomedViewport.minX).toBe(400); // 800/2 + expect(zoomedViewport.maxY - zoomedViewport.minY).toBe(300); // 600/2 + + // Pan by (50, 30) — adds to offset set by zoomAt + // zoomAt(400,300,2): offsetX = 400-(400-0)*2 = -400, offsetY = 300-(300-0)*2 = -300 + zoomPan.pan(50, 30); + const transform = zoomPan.getTransform(); + expect(transform.e).toBe(-350); // -400 + 50 + expect(transform.f).toBe(-270); // -300 + 30 + + // worldToScreen / screenToWorld roundtrip + const worldPt = { x: 150, y: 75 }; + const screenPt = zoomPan.worldToScreen(worldPt.x, worldPt.y); + const backToWorld = zoomPan.screenToWorld(screenPt.x, screenPt.y); + expect(backToWorld.x).toBeCloseTo(worldPt.x, 5); + expect(backToWorld.y).toBeCloseTo(worldPt.y, 5); + + // Another roundtrip with different point + const worldPt2 = { x: -50, y: 200 }; + const screenPt2 = zoomPan.worldToScreen(worldPt2.x, worldPt2.y); + const back2 = zoomPan.screenToWorld(screenPt2.x, screenPt2.y); + expect(back2.x).toBeCloseTo(worldPt2.x, 5); + expect(back2.y).toBeCloseTo(worldPt2.y, 5); + }); + }); + + // ─── Step 6: Selection ────────────────────────────────────────────────────── + describe('Step 6: Selection (click and box)', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1, color: '#ff0000' })); + + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'), + makeCircle('el-circle-1', 400, 400, 40, 'layer-default'), + ]; + for (const el of elements) spatialIndex.insert(el); + }); + + it('should click-select a single element', () => { + // Click near the line's midpoint (50, 50) + const hit = selectionEngine.clickSelect(50, 50, elements); + expect(hit).not.toBeNull(); + expect(hit?.id).toBe('el-line-1'); + + const selectedIds = selectionEngine.getSelectedIds(); + expect(selectedIds.size).toBe(1); + expect(selectedIds.has('el-line-1')).toBe(true); + }); + + it('should click-select the rect element', () => { + // Click at rect center (200, 200) + const hit = selectionEngine.clickSelect(200, 200, elements); + expect(hit).not.toBeNull(); + expect(hit?.id).toBe('el-rect-1'); + expect(selectionEngine.getSelectedIds().has('el-rect-1')).toBe(true); + }); + + it('should clear selection when clicking empty space', () => { + // First select an element + selectionEngine.clickSelect(50, 50, elements); + expect(selectionEngine.getSelectedIds().size).toBe(1); + + // Click in empty area (far from elements) + const hit = selectionEngine.clickSelect(1000, 1000, elements); + expect(hit).toBeNull(); + expect(selectionEngine.getSelectedIds().size).toBe(0); + }); + + it('should box-select multiple elements (window selection)', () => { + // Window selection: left-to-right drag enclosing line and rect + // line bbox: minX=-50, minY=-50, maxX=150, maxY=150 + // rect bbox: minX=160, minY=170, maxX=240, maxY=230 + // Need a box that fully encloses both + selectionEngine.startBoxSelect(-100, -100); + selectionEngine.updateBoxSelect(300, 300, elements); + const selected = selectionEngine.finishBoxSelect(elements); + + // Both line and rect should be fully enclosed + expect(selected.length).toBeGreaterThanOrEqual(2); + const selectedIds = selectionEngine.getSelectedIds(); + expect(selectedIds.has('el-line-1')).toBe(true); + expect(selectedIds.has('el-rect-1')).toBe(true); + }); + + it('should box-select with crossing mode (right-to-left drag)', () => { + // Crossing selection: right-to-left drag (start X > end X) + // Use a box that intersects the circle but doesn't fully enclose it + // circle bbox: minX=360, minY=360, maxX=440, maxY=440 + selectionEngine.startBoxSelect(420, 420); + selectionEngine.updateBoxSelect(380, 380, elements); + const selected = selectionEngine.finishBoxSelect(elements); + + // Circle should be selected (intersecting) + expect(selected.length).toBeGreaterThanOrEqual(1); + expect(selectionEngine.getSelectedIds().has('el-circle-1')).toBe(true); + }); + + it('should support additive selection (shift-click)', () => { + // Select line first + selectionEngine.clickSelect(50, 50, elements); + expect(selectionEngine.getSelectedIds().size).toBe(1); + + // Additive select rect + selectionEngine.setOptions({ additive: true }); + selectionEngine.clickSelect(200, 200, elements); + + const selectedIds = selectionEngine.getSelectedIds(); + expect(selectedIds.size).toBe(2); + expect(selectedIds.has('el-line-1')).toBe(true); + expect(selectedIds.has('el-rect-1')).toBe(true); + }); + }); + + // ─── Step 7: Group selected elements ───────────────────────────────────────── + describe('Step 7: Grouping', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1 })); + + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'), + makeCircle('el-circle-1', 400, 400, 40, 'layer-default'), + ]; + for (const el of elements) spatialIndex.insert(el); + }); + + it('should group selected elements and verify membership', () => { + // Select two elements + selectionEngine.selectByIds(['el-line-1', 'el-rect-1']); + const selectedIds = selectionEngine.getSelectedIds(); + expect(selectedIds.size).toBe(2); + + // Create group from selected elements + const group = groupManager.createGroup(['el-line-1', 'el-rect-1'], 'Test Group'); + expect(group.id).toBeDefined(); + expect(group.name).toBe('Test Group'); + expect(group.elementIds).toHaveLength(2); + expect(group.elementIds).toContain('el-line-1'); + expect(group.elementIds).toContain('el-rect-1'); + + // Verify group membership + expect(groupManager.getGroupForElement('el-line-1')).toBe(group.id); + expect(groupManager.getGroupForElement('el-rect-1')).toBe(group.id); + expect(groupManager.getGroupForElement('el-circle-1')).toBeNull(); + + // Verify grouped elements set + const grouped = groupManager.getGroupedElements(); + expect(grouped.size).toBe(2); + expect(grouped.has('el-line-1')).toBe(true); + expect(grouped.has('el-rect-1')).toBe(true); + + // Verify group retrieval + const retrieved = groupManager.getGroup(group.id); + expect(retrieved).toBeDefined(); + expect(retrieved?.name).toBe('Test Group'); + + // Ungroup + const ungroupedIds = groupManager.ungroup(group.id); + expect(ungroupedIds).toHaveLength(2); + expect(groupManager.getGroups()).toHaveLength(0); + expect(groupManager.getGroupedElements().size).toBe(0); + }); + }); + + // ─── Step 8: Snap to endpoints/midpoints ───────────────────────────────────── + describe('Step 8: Snap engine', () => { + beforeEach(() => { + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeCircle('el-circle-1', 200, 200, 40, 'layer-default'), + ]; + snapEngine.setElements(elements); + }); + + it('should snap to line endpoint', () => { + // Line endpoints: (0,0) and (100,100) + // Click near (0, 0) within tolerance + const result = snapEngine.snap(2, 2); + expect(result.point).not.toBeNull(); + expect(result.point?.type).toBe('endpoint'); + expect(result.point?.x).toBeCloseTo(0, 1); + expect(result.point?.y).toBeCloseTo(0, 1); + }); + + it('should snap to second line endpoint', () => { + const result = snapEngine.snap(98, 98); + expect(result.point).not.toBeNull(); + expect(result.point?.type).toBe('endpoint'); + expect(result.point?.x).toBeCloseTo(100, 1); + expect(result.point?.y).toBeCloseTo(100, 1); + }); + + it('should snap to line midpoint', () => { + // Line midpoint: (50, 50) + const result = snapEngine.snap(52, 52); + expect(result.point).not.toBeNull(); + // Endpoint (0,0) is ~74 units away, midpoint (50,50) is ~2.8 units away + // But endpoint priority > midpoint priority, so if both within tolerance * 1.5... + // tolerance=10, so endpoint at distance ~74 is NOT within 10*1.5=15 + // midpoint at distance ~2.8 IS within 15 + expect(result.point?.type).toBe('midpoint'); + expect(result.point?.x).toBeCloseTo(50, 1); + expect(result.point?.y).toBeCloseTo(50, 1); + }); + + it('should snap to circle center', () => { + // Circle center: (200, 200) + const result = snapEngine.snap(202, 202); + expect(result.point).not.toBeNull(); + expect(result.point?.type).toBe('center'); + expect(result.point?.x).toBeCloseTo(200, 1); + expect(result.point?.y).toBeCloseTo(200, 1); + }); + + it('should return null when no snap point is near', () => { + // Click far from any element + const result = snapEngine.snap(500, 500); + expect(result.point).toBeNull(); + }); + + it('should return preview candidates', () => { + const result = snapEngine.snap(2, 2); + expect(result.preview.length).toBeGreaterThan(0); + }); + }); + + // ─── Step 9: History (push, undo, redo) ────────────────────────────────────── + describe('Step 9: History management', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + }); + + it('should push snapshot, modify, undo, redo and verify state restored', () => { + const initialElements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + ]; + const initialLayers = layerManager.getLayers(); + + // Initialize history with initial state + historyManager.initialize(makeSnapshot(initialElements, initialLayers)); + expect(historyManager.getCurrentState()?.elements).toHaveLength(1); + expect(historyManager.canUndo()).toBe(false); + expect(historyManager.canRedo()).toBe(false); + + // Push a new state: add a rect element + const modifiedElements = [ + ...initialElements, + makeRect('el-rect-1', 50, 50, 80, 60, 'layer-default'), + ]; + historyManager.pushSnapshot(makeSnapshot(modifiedElements, initialLayers), 'Add rect'); + + expect(historyManager.getCurrentState()?.elements).toHaveLength(2); + expect(historyManager.canUndo()).toBe(true); + expect(historyManager.canRedo()).toBe(false); + + // Undo: should restore to 1 element + const undone = historyManager.undo(); + expect(undone).not.toBeNull(); + expect(undone?.elements).toHaveLength(1); + expect(undone?.elements[0].id).toBe('el-line-1'); + expect(historyManager.canRedo()).toBe(true); + + // Redo: should restore to 2 elements + const redone = historyManager.redo(); + expect(redone).not.toBeNull(); + expect(redone?.elements).toHaveLength(2); + expect(redone?.elements[1].id).toBe('el-rect-1'); + expect(historyManager.canUndo()).toBe(true); + expect(historyManager.canRedo()).toBe(false); + }); + + it('should handle multiple undo/redo cycles', () => { + const layers = layerManager.getLayers(); + + // Initialize + historyManager.initialize(makeSnapshot([], layers)); + + // Push state 1 + const els1 = [makeLine('el-1', 0, 0, 50, 50, 'layer-default')]; + historyManager.pushSnapshot(makeSnapshot(els1, layers), 'Add line 1'); + + // Push state 2 + const els2 = [...els1, makeLine('el-2', 100, 100, 200, 200, 'layer-default')]; + historyManager.pushSnapshot(makeSnapshot(els2, layers), 'Add line 2'); + + // Push state 3 + const els3 = [...els2, makeLine('el-3', 300, 300, 400, 400, 'layer-default')]; + historyManager.pushSnapshot(makeSnapshot(els3, layers), 'Add line 3'); + + expect(historyManager.getCurrentState()?.elements).toHaveLength(3); + expect(historyManager.getUndoCount()).toBe(3); + + // Undo twice + historyManager.undo(); + expect(historyManager.getCurrentState()?.elements).toHaveLength(2); + historyManager.undo(); + expect(historyManager.getCurrentState()?.elements).toHaveLength(1); + expect(historyManager.getRedoCount()).toBe(2); + + // Redo once + historyManager.redo(); + expect(historyManager.getCurrentState()?.elements).toHaveLength(2); + expect(historyManager.getRedoCount()).toBe(1); + }); + }); + + // ─── Step 10: Layer toggle (hide layer, verify not rendered/selectable) ────── + describe('Step 10: Layer toggle affects rendering and selection', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-hidden', { sortOrder: 1 })); + + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeRect('el-rect-1', 200, 200, 80, 60, 'layer-hidden'), + ]; + for (const el of elements) spatialIndex.insert(el); + }); + + it('should not render elements on hidden layers', () => { + // Initially both layers visible — render should work + expect(() => renderEngine.render()).not.toThrow(); + + // Hide layer-hidden + layerManager.toggleVisibility('layer-hidden'); + expect(layerManager.getLayer('layer-hidden')?.visible).toBe(false); + + // Render should still not crash (just skips hidden layer elements) + expect(() => renderEngine.render()).not.toThrow(); + + // getVisibleLayers should not include hidden layer + const visible = layerManager.getVisibleLayers(); + expect(visible.some(l => l.id === 'layer-hidden')).toBe(false); + }); + + it('should not select elements on hidden layers via hitTest', () => { + // Hide layer-hidden + layerManager.toggleVisibility('layer-hidden'); + + // Try to click-select the rect on hidden layer + const hit = selectionEngine.clickSelect(200, 200, elements); + expect(hit).toBeNull(); + expect(selectionEngine.getSelectedIds().size).toBe(0); + }); + + it('should not select elements on locked layers via hitTest', () => { + // Lock layer-hidden (visible but locked → not in getVisibleLayers) + layerManager.toggleLock('layer-hidden'); + + // Try to click-select the rect on locked layer + const hit = selectionEngine.clickSelect(200, 200, elements); + expect(hit).toBeNull(); + }); + + it('should still select elements on visible, unlocked layers', () => { + // Click on line (layer-default, visible, unlocked) + const hit = selectionEngine.clickSelect(50, 50, elements); + expect(hit).not.toBeNull(); + expect(hit?.id).toBe('el-line-1'); + }); + }); + + // ─── Step 11: Zoom fit to all elements ─────────────────────────────────────── + describe('Step 11: Zoom fit to all elements', () => { + it('should change scale when fitting to elements', () => { + const initialScale = zoomPan.getScale(); + expect(initialScale).toBe(1); + + // Elements spread across a large area + const fitElements = [ + { x: 0, y: 0, width: 10, height: 10 }, + { x: 500, y: 500, width: 10, height: 10 }, + { x: 1000, y: 1000, width: 10, height: 10 }, + ]; + + zoomPan.zoomFit(fitElements); + + const newScale = zoomPan.getScale(); + // Canvas is 800x600, elements span ~1010 units, padding=40 + // scaleX = (800-80)/1010 ≈ 0.713, scaleY = (600-80)/1010 ≈ 0.515 + // scale = min(0.713, 0.515) ≈ 0.515 + expect(newScale).not.toBe(1); + expect(newScale).toBeGreaterThan(0); + expect(newScale).toBeLessThan(1); + }); + + it('should not change scale when no elements', () => { + const initialScale = zoomPan.getScale(); + zoomPan.zoomFit([]); + expect(zoomPan.getScale()).toBe(initialScale); + }); + + it('should fit and then reset', () => { + zoomPan.zoomFit([{ x: 100, y: 100, width: 50, height: 50 }]); + expect(zoomPan.getScale()).not.toBe(1); + + zoomPan.reset(); + expect(zoomPan.getScale()).toBe(1); + const transform = zoomPan.getTransform(); + expect(transform.e).toBe(0); + expect(transform.f).toBe(0); + }); + }); + + // ─── Step 12: Reset everything, verify clean state ─────────────────────────── + describe('Step 12: Reset everything', () => { + beforeEach(() => { + layerManager.addLayer(makeLayer('layer-default', { sortOrder: 0 })); + layerManager.addLayer(makeLayer('layer-arch', { sortOrder: 1 })); + + elements = [ + makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'), + makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'), + ]; + for (const el of elements) spatialIndex.insert(el); + + // Modify state + zoomPan.zoomAt(400, 300, 2); + zoomPan.pan(50, 30); + selectionEngine.selectByIds(['el-line-1', 'el-rect-1']); + groupManager.createGroup(['el-line-1', 'el-rect-1'], 'Test'); + historyManager.initialize(makeSnapshot(elements, layerManager.getLayers())); + historyManager.pushSnapshot(makeSnapshot([...elements, makeCircle('el-c', 300, 300, 20)], layerManager.getLayers()), 'Add circle'); + }); + + it('should reset all components to clean state', () => { + // Verify dirty state before reset + expect(zoomPan.getScale()).not.toBe(1); + expect(selectionEngine.getSelectedIds().size).toBe(2); + expect(groupManager.getGroups().length).toBe(1); + expect(spatialIndex.search({ minX: -1000, minY: -1000, maxX: 1000, maxY: 1000 }).length).toBe(2); + expect(layerManager.getLayers().length).toBe(2); + expect(historyManager.canUndo()).toBe(true); + + // Reset all + zoomPan.reset(); + selectionEngine.clearSelection(); + groupManager.clear(); + spatialIndex.clear(); + layerManager.clear(); + historyManager.clear(); + + // Verify clean state + expect(zoomPan.getScale()).toBe(1); + expect(zoomPan.getTransform().e).toBe(0); + expect(zoomPan.getTransform().f).toBe(0); + + expect(selectionEngine.getSelectedIds().size).toBe(0); + + expect(groupManager.getGroups()).toHaveLength(0); + expect(groupManager.getGroupedElements().size).toBe(0); + + expect(spatialIndex.search({ minX: -1000, minY: -1000, maxX: 1000, maxY: 1000 })).toHaveLength(0); + + expect(layerManager.getLayers()).toHaveLength(0); + expect(layerManager.getActiveLayerId()).toBe(''); + + expect(historyManager.getCurrentState()).toBeNull(); + expect(historyManager.canUndo()).toBe(false); + expect(historyManager.canRedo()).toBe(false); + }); + }); + + // ─── Full Workflow Integration ────────────────────────────────────────────── + describe('Full workflow: init → layers → elements → render → zoom → select → group → snap → history → toggle → fit → reset', () => { + it('should execute the complete CAD workflow end-to-end', () => { + // ── 1. Initialize ── + expect(renderEngine).toBeDefined(); + expect(selectionEngine).toBeDefined(); + + // ── 2. Create layers ── + const defaultLayer = makeLayer('layer-default', { sortOrder: 0, name: 'Default' }); + const archLayer = makeLayer('layer-arch', { sortOrder: 1, name: 'Architecture', color: '#ff0000' }); + layerManager.addLayer(defaultLayer); + layerManager.addLayer(archLayer); + expect(layerManager.getLayers()).toHaveLength(2); + + // ── 3. Add elements to different layers ── + const line1 = makeLine('el-line-1', 0, 0, 100, 100, 'layer-default'); + const rect1 = makeRect('el-rect-1', 200, 200, 80, 60, 'layer-arch'); + const circle1 = makeCircle('el-circle-1', 400, 300, 40, 'layer-default'); + elements = [line1, rect1, circle1]; + for (const el of elements) spatialIndex.insert(el); + + // Verify spatial index has all elements + const allFound = spatialIndex.search({ minX: -200, minY: -200, maxX: 600, maxY: 600 }); + expect(allFound).toHaveLength(3); + + // ── 4. Render ── + expect(() => renderEngine.render()).not.toThrow(); + + // ── 5. Zoom/Pan ── + zoomPan.zoomAt(400, 300, 1.5); + expect(zoomPan.getScale()).toBe(1.5); + // zoomAt(400,300,1.5): offsetX = 400-(400-0)*1.5 = -200, then pan(20,10) → -180 + zoomPan.pan(20, 10); + expect(zoomPan.getTransform().e).toBe(-180); + + // Roundtrip + const wpt = { x: 100, y: 100 }; + const spt = zoomPan.worldToScreen(wpt.x, wpt.y); + const back = zoomPan.screenToWorld(spt.x, spt.y); + expect(back.x).toBeCloseTo(wpt.x, 3); + expect(back.y).toBeCloseTo(wpt.y, 3); + + // ── 6. Selection ── + // Click select line at midpoint (50, 50) + const hit = selectionEngine.clickSelect(50, 50, elements); + expect(hit?.id).toBe('el-line-1'); + + // Box select line + rect (window selection) + selectionEngine.clearSelection(); + selectionEngine.startBoxSelect(-100, -100); + selectionEngine.updateBoxSelect(300, 300, elements); + const boxSelected = selectionEngine.finishBoxSelect(elements); + expect(boxSelected.length).toBeGreaterThanOrEqual(2); + + // ── 7. Group selected elements ── + const selectedIds = Array.from(selectionEngine.getSelectedIds()); + const group = groupManager.createGroup(selectedIds, 'Workflow Group'); + expect(group.elementIds.length).toBeGreaterThanOrEqual(2); + expect(groupManager.getGroup(group.id)).toBeDefined(); + + // ── 8. Snap ── + snapEngine.setElements(elements); + const snapResult = snapEngine.snap(2, 2); // Near line endpoint (0,0) + expect(snapResult.point).not.toBeNull(); + expect(snapResult.point?.type).toBe('endpoint'); + + // ── 9. History ── + const currentLayers = layerManager.getLayers(); + historyManager.initialize(makeSnapshot(elements, currentLayers)); + const modifiedEls = [...elements, makeRect('el-rect-2', 500, 500, 30, 30, 'layer-default')]; + historyManager.pushSnapshot(makeSnapshot(modifiedEls, currentLayers), 'Add rect 2'); + + expect(historyManager.getCurrentState()?.elements).toHaveLength(4); + expect(historyManager.canUndo()).toBe(true); + + const undone = historyManager.undo(); + expect(undone?.elements).toHaveLength(3); + + const redone = historyManager.redo(); + expect(redone?.elements).toHaveLength(4); + + // ── 10. Layer toggle ── + layerManager.toggleVisibility('layer-arch'); + expect(layerManager.getLayer('layer-arch')?.visible).toBe(false); + + // Elements on hidden layer should not be selectable + const hitAfterHide = selectionEngine.clickSelect(200, 200, elements); + expect(hitAfterHide).toBeNull(); + + // Restore visibility + layerManager.toggleVisibility('layer-arch'); + expect(layerManager.getLayer('layer-arch')?.visible).toBe(true); + + // ── 11. Zoom fit ── + zoomPan.reset(); + expect(zoomPan.getScale()).toBe(1); + + zoomPan.zoomFit(elements); + expect(zoomPan.getScale()).not.toBe(1); + + // ── 12. Reset everything ── + zoomPan.reset(); + selectionEngine.clearSelection(); + groupManager.clear(); + spatialIndex.clear(); + layerManager.clear(); + historyManager.clear(); + + expect(zoomPan.getScale()).toBe(1); + expect(selectionEngine.getSelectedIds().size).toBe(0); + expect(groupManager.getGroups()).toHaveLength(0); + expect(spatialIndex.search({ minX: -1000, minY: -1000, maxX: 1000, maxY: 1000 })).toHaveLength(0); + expect(layerManager.getLayers()).toHaveLength(0); + expect(historyManager.getCurrentState()).toBeNull(); + }); + }); + + // ─── CommandRegistry Integration ───────────────────────────────────────────── + describe('CommandRegistry integration', () => { + it('should look up drawing commands', () => { + const lineCmd = commandRegistry.lookup('LINE'); + expect(lineCmd).not.toBeNull(); + expect(lineCmd?.toolId).toBe('line'); + + const circleCmd = commandRegistry.lookup('C'); + expect(circleCmd?.name).toBe('CIRCLE'); + expect(circleCmd?.toolId).toBe('circle'); + }); + + it('should provide autocomplete suggestions', () => { + const suggestions = commandRegistry.autocomplete('LI'); + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions.some(c => c.name === 'LINE')).toBe(true); + }); + + it('should return null for unknown commands', () => { + expect(commandRegistry.lookup('UNKNOWN')).toBeNull(); + }); + }); +}); diff --git a/frontend/tests/LayerManager.test.ts b/frontend/tests/LayerManager.test.ts new file mode 100644 index 0000000..f338afb --- /dev/null +++ b/frontend/tests/LayerManager.test.ts @@ -0,0 +1,346 @@ +/** + * LayerManager Tests – Layer-Verwaltung + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { LayerManager } from '../src/canvas/LayerManager'; +import type { CADLayer } from '../src/types/cad.types'; + +function makeLayer(id: string, overrides: Partial = {}): CADLayer { + return { + id, + name: id, + visible: true, + locked: false, + color: '#ffffff', + lineType: 'solid', + transparency: 0, + sortOrder: 0, + parentId: null, + ...overrides, + }; +} + +describe('LayerManager', () => { + let lm: LayerManager; + + beforeEach(() => { + lm = new LayerManager(); + }); + + describe('addLayer & getLayer', () => { + it('should add a layer and retrieve it by id', () => { + const layer = makeLayer('layer-1', { name: 'Walls' }); + lm.addLayer(layer); + expect(lm.getLayer('layer-1')).toBeDefined(); + expect(lm.getLayer('layer-1')?.name).toBe('Walls'); + }); + + it('should set first added layer as active', () => { + lm.addLayer(makeLayer('layer-1')); + expect(lm.getActiveLayerId()).toBe('layer-1'); + }); + + it('should not set active layer if already set', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + expect(lm.getActiveLayerId()).toBe('layer-1'); + }); + + it('should return undefined for non-existent layer', () => { + expect(lm.getLayer('non-existent')).toBeUndefined(); + }); + }); + + describe('removeLayer', () => { + it('should remove a layer', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.removeLayer('layer-1'); + expect(lm.getLayer('layer-1')).toBeUndefined(); + expect(lm.getLayers().length).toBe(1); + }); + + it('should switch active layer when removing the active one', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.setActiveLayer('layer-1'); + lm.removeLayer('layer-1'); + expect(lm.getActiveLayerId()).toBe('layer-2'); + }); + + it('should have empty active layer id when removing last layer', () => { + lm.addLayer(makeLayer('layer-1')); + lm.removeLayer('layer-1'); + expect(lm.getActiveLayerId()).toBe(''); + }); + }); + + describe('getLayers', () => { + it('should return layers sorted by sortOrder', () => { + lm.addLayer(makeLayer('layer-3', { sortOrder: 3 })); + lm.addLayer(makeLayer('layer-1', { sortOrder: 1 })); + lm.addLayer(makeLayer('layer-2', { sortOrder: 2 })); + + const layers = lm.getLayers(); + expect(layers[0].id).toBe('layer-1'); + expect(layers[1].id).toBe('layer-2'); + expect(layers[2].id).toBe('layer-3'); + }); + + it('should return empty array when no layers', () => { + expect(lm.getLayers()).toEqual([]); + }); + }); + + describe('getVisibleLayers', () => { + it('should return only visible and unlocked layers', () => { + lm.addLayer(makeLayer('layer-1', { visible: true, locked: false })); + lm.addLayer(makeLayer('layer-2', { visible: false, locked: false })); + lm.addLayer(makeLayer('layer-3', { visible: true, locked: true })); + lm.addLayer(makeLayer('layer-4', { visible: true, locked: false })); + + const visible = lm.getVisibleLayers(); + expect(visible.length).toBe(2); + const ids = visible.map(l => l.id); + expect(ids).toContain('layer-1'); + expect(ids).toContain('layer-4'); + }); + }); + + describe('setActiveLayer & getActiveLayer', () => { + it('should set active layer if it exists', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.setActiveLayer('layer-2'); + expect(lm.getActiveLayerId()).toBe('layer-2'); + expect(lm.getActiveLayer()?.id).toBe('layer-2'); + }); + + it('should not set active layer if it does not exist', () => { + lm.addLayer(makeLayer('layer-1')); + lm.setActiveLayer('non-existent'); + expect(lm.getActiveLayerId()).toBe('layer-1'); + }); + }); + + describe('toggleVisibility', () => { + it('should toggle layer visibility', () => { + lm.addLayer(makeLayer('layer-1', { visible: true })); + lm.toggleVisibility('layer-1'); + expect(lm.getLayer('layer-1')?.visible).toBe(false); + lm.toggleVisibility('layer-1'); + expect(lm.getLayer('layer-1')?.visible).toBe(true); + }); + }); + + describe('toggleLock', () => { + it('should toggle layer lock state', () => { + lm.addLayer(makeLayer('layer-1', { locked: false })); + lm.toggleLock('layer-1'); + expect(lm.getLayer('layer-1')?.locked).toBe(true); + lm.toggleLock('layer-1'); + expect(lm.getLayer('layer-1')?.locked).toBe(false); + }); + }); + + describe('clear', () => { + it('should clear all layers and reset active', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.clear(); + expect(lm.getLayers().length).toBe(0); + expect(lm.getActiveLayerId()).toBe(''); + }); + }); + + describe('renameLayer', () => { + it('should rename a layer', () => { + lm.addLayer(makeLayer('layer-1', { name: 'Old Name' })); + lm.renameLayer('layer-1', 'New Name'); + expect(lm.getLayer('layer-1')?.name).toBe('New Name'); + }); + + it('should do nothing for non-existent layer', () => { + lm.renameLayer('non-existent', 'New Name'); + expect(lm.getLayer('non-existent')).toBeUndefined(); + }); + }); + + describe('updateLayer', () => { + it('should update multiple layer properties', () => { + lm.addLayer(makeLayer('layer-1', { color: '#ffffff', transparency: 0 })); + lm.updateLayer('layer-1', { color: '#ff0000', transparency: 50 }); + const layer = lm.getLayer('layer-1'); + expect(layer?.color).toBe('#ff0000'); + expect(layer?.transparency).toBe(50); + }); + }); + + describe('setParent', () => { + it('should set parent for a layer', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.setParent('layer-2', 'layer-1'); + expect(lm.getLayer('layer-2')?.parentId).toBe('layer-1'); + }); + + it('should prevent circular references', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.setParent('layer-1', 'layer-2'); + lm.setParent('layer-2', 'layer-1'); // Would create cycle + expect(lm.getLayer('layer-2')?.parentId).toBe(null); + }); + + it('should allow setting parent to null', () => { + lm.addLayer(makeLayer('layer-1')); + lm.addLayer(makeLayer('layer-2')); + lm.setParent('layer-2', 'layer-1'); + lm.setParent('layer-2', null); + expect(lm.getLayer('layer-2')?.parentId).toBe(null); + }); + }); + + describe('getChildLayers & getLayerTree', () => { + it('should get child layers of a parent', () => { + lm.addLayer(makeLayer('parent', { sortOrder: 0 })); + lm.addLayer(makeLayer('child-1', { parentId: 'parent', sortOrder: 1 })); + lm.addLayer(makeLayer('child-2', { parentId: 'parent', sortOrder: 2 })); + lm.addLayer(makeLayer('other', { sortOrder: 3 })); + + const children = lm.getChildLayers('parent'); + expect(children.length).toBe(2); + }); + + it('should get child layers for null parent (root layers)', () => { + lm.addLayer(makeLayer('root-1', { parentId: null })); + lm.addLayer(makeLayer('root-2', { parentId: null })); + lm.addLayer(makeLayer('child', { parentId: 'root-1' })); + + const roots = lm.getChildLayers(null); + expect(roots.length).toBe(2); + }); + + it('should build a tree structure', () => { + lm.addLayer(makeLayer('root', { sortOrder: 0 })); + lm.addLayer(makeLayer('child-a', { parentId: 'root', sortOrder: 1 })); + lm.addLayer(makeLayer('child-b', { parentId: 'root', sortOrder: 2 })); + lm.addLayer(makeLayer('grandchild', { parentId: 'child-a', sortOrder: 3 })); + + const tree = lm.getLayerTree(); + expect(tree.length).toBe(1); + expect(tree[0].id).toBe('root'); + expect(tree[0].children.length).toBe(2); + expect(tree[0].children[0].children.length).toBe(1); + expect(tree[0].children[0].children[0].id).toBe('grandchild'); + }); + }); + + describe('moveLayer', () => { + it('should move layer to new sort order', () => { + lm.addLayer(makeLayer('layer-1', { sortOrder: 0 })); + lm.moveLayer('layer-1', 5); + expect(lm.getLayer('layer-1')?.sortOrder).toBe(5); + }); + }); + + describe('duplicateLayer', () => { + it('should create a copy of the layer', () => { + lm.addLayer(makeLayer('layer-1', { name: 'Original', sortOrder: 1 })); + const copy = lm.duplicateLayer('layer-1'); + expect(copy).not.toBeNull(); + expect(copy?.id).not.toBe('layer-1'); + expect(copy?.name).toContain('Kopie'); + expect(copy?.sortOrder).toBe(2); + expect(lm.getLayers().length).toBe(2); + }); + + it('should return null for non-existent layer', () => { + expect(lm.duplicateLayer('non-existent')).toBeNull(); + }); + }); + + describe('filterLayers', () => { + beforeEach(() => { + lm.addLayer(makeLayer('layer-1', { name: 'Walls', visible: true, locked: false, color: '#ff0000' })); + lm.addLayer(makeLayer('layer-2', { name: 'Doors', visible: false, locked: true, color: '#00ff00' })); + lm.addLayer(makeLayer('layer-3', { name: 'Windows', visible: true, locked: false, color: '#ff0000' })); + }); + + it('should filter by visible', () => { + const result = lm.filterLayers({ visible: true }); + expect(result.length).toBe(2); + }); + + it('should filter by locked', () => { + const result = lm.filterLayers({ locked: true }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('layer-2'); + }); + + it('should filter by color', () => { + const result = lm.filterLayers({ color: '#ff0000' }); + expect(result.length).toBe(2); + }); + + it('should filter by name contains (case insensitive)', () => { + const result = lm.filterLayers({ nameContains: 'wall' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('layer-1'); + }); + + it('should filter by multiple criteria', () => { + const result = lm.filterLayers({ visible: true, color: '#ff0000' }); + expect(result.length).toBe(2); + }); + }); + + describe('getDescendantIds', () => { + it('should get all descendant IDs recursively', () => { + lm.addLayer(makeLayer('root')); + lm.addLayer(makeLayer('child-1', { parentId: 'root' })); + lm.addLayer(makeLayer('child-2', { parentId: 'root' })); + lm.addLayer(makeLayer('grandchild-1', { parentId: 'child-1' })); + lm.addLayer(makeLayer('grandchild-2', { parentId: 'child-1' })); + + const descendants = lm.getDescendantIds('root'); + expect(descendants.length).toBe(4); + expect(descendants).toContain('child-1'); + expect(descendants).toContain('child-2'); + expect(descendants).toContain('grandchild-1'); + expect(descendants).toContain('grandchild-2'); + }); + + it('should return empty array for layer with no children', () => { + lm.addLayer(makeLayer('lonely')); + expect(lm.getDescendantIds('lonely')).toEqual([]); + }); + }); + + describe('isLocked', () => { + it('should return true for locked layer', () => { + lm.addLayer(makeLayer('layer-1', { locked: true })); + expect(lm.isLocked('layer-1')).toBe(true); + }); + + it('should return false for unlocked layer', () => { + lm.addLayer(makeLayer('layer-1', { locked: false })); + expect(lm.isLocked('layer-1')).toBe(false); + }); + + it('should return false for non-existent layer', () => { + expect(lm.isLocked('non-existent')).toBe(false); + }); + }); + + describe('getLayerColor', () => { + it('should return layer color', () => { + lm.addLayer(makeLayer('layer-1', { color: '#abcdef' })); + expect(lm.getLayerColor('layer-1')).toBe('#abcdef'); + }); + + it('should return undefined for non-existent layer', () => { + expect(lm.getLayerColor('non-existent')).toBeUndefined(); + }); + }); +}); diff --git a/frontend/tests/RenderEngine.test.ts b/frontend/tests/RenderEngine.test.ts new file mode 100644 index 0000000..b7df39d --- /dev/null +++ b/frontend/tests/RenderEngine.test.ts @@ -0,0 +1,352 @@ +/** + * RenderEngine Tests – Rendering, grid, viewport, hit testing + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { RenderEngine } from '../src/canvas/RenderEngine'; +import { ZoomPanController } from '../src/canvas/ZoomPanController'; +import { SpatialIndex } from '../src/canvas/SpatialIndex'; +import { LayerManager } from '../src/canvas/LayerManager'; +import type { CADElement, CADLayer } from '../src/types/cad.types'; + +function createMockCanvas(w = 800, h = 600): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.fillRect = (() => {}) as any; + ctx.strokeRect = (() => {}) as any; + ctx.beginPath = (() => {}) as any; + ctx.moveTo = (() => {}) as any; + ctx.lineTo = (() => {}) as any; + ctx.stroke = (() => {}) as any; + ctx.fill = (() => {}) as any; + ctx.save = (() => {}) as any; + ctx.restore = (() => {}) as any; + ctx.scale = (() => {}) as any; + ctx.arc = (() => {}) as any; + ctx.setLineDash = (() => {}) as any; + ctx.clearRect = (() => {}) as any; + ctx.translate = (() => {}) as any; + ctx.rotate = (() => {}) as any; + ctx.clip = (() => {}) as any; + ctx.fillText = (() => {}) as any; + ctx.quadraticCurveTo = (() => {}) as any; + ctx.closePath = (() => {}) as any; + } + return canvas; +} + +function makeLayer(id: string, overrides: Partial = {}): CADLayer { + return { + id, + name: id, + visible: true, + locked: false, + color: '#ffffff', + lineType: 'solid', + transparency: 0, + sortOrder: 0, + parentId: null, + ...overrides, + }; +} + +function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement { + return { + id, + type: 'line', + layerId: 'layer-1', + x: (x1 + x2) / 2, + y: (y1 + y2) / 2, + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + properties: { x1, y1, x2, y2 }, + }; +} + +function makeRect(id: string, cx: number, cy: number, w: number, h: number): CADElement { + return { + id, + type: 'rect', + layerId: 'layer-1', + x: cx, + y: cy, + width: w, + height: h, + properties: {}, + }; +} + +function makeCircle(id: string, cx: number, cy: number, r: number): CADElement { + return { + id, + type: 'circle', + layerId: 'layer-1', + x: cx, + y: cy, + width: r * 2, + height: r * 2, + properties: { radius: r }, + }; +} + +describe('RenderEngine', () => { + let canvas: HTMLCanvasElement; + let zpc: ZoomPanController; + let spatialIndex: SpatialIndex; + let layerManager: LayerManager; + let engine: RenderEngine; + + beforeEach(() => { + canvas = createMockCanvas(800, 600); + zpc = new ZoomPanController(canvas); + spatialIndex = new SpatialIndex(); + layerManager = new LayerManager(); + layerManager.addLayer(makeLayer('layer-1')); + engine = new RenderEngine(canvas, zpc, spatialIndex, layerManager); + }); + + describe('constructor', () => { + it('should construct without error', () => { + expect(engine).toBeDefined(); + }); + + it('should have default options with showGrid=true', () => { + const opts = engine.getOptions(); + expect(opts.showGrid).toBe(true); + expect(opts.gridSize).toBe(20); + }); + + it('should have empty selection state', () => { + const sel = engine.getSelection(); + expect(sel.selectedIds.size).toBe(0); + expect(sel.hoverId).toBeNull(); + }); + }); + + describe('setOptions & getOptions', () => { + it('should update options partially', () => { + engine.setOptions({ showGrid: false }); + expect(engine.getOptions().showGrid).toBe(false); + }); + + it('should preserve other options when updating one', () => { + engine.setOptions({ gridSize: 50 }); + const opts = engine.getOptions(); + expect(opts.gridSize).toBe(50); + expect(opts.showGrid).toBe(true); + }); + }); + + describe('setSelection & getSelection', () => { + it('should update selection state', () => { + engine.setSelection({ hoverId: 'el-1' }); + expect(engine.getSelection().hoverId).toBe('el-1'); + }); + + it('should set selected ids', () => { + engine.setSelection({ selectedIds: new Set(['a', 'b']) }); + expect(engine.getSelection().selectedIds.size).toBe(2); + }); + }); + + describe('render with empty elements', () => { + it('should not throw when rendering with no elements', () => { + expect(() => engine.render()).not.toThrow(); + }); + + it('should call ctx.fillRect for background', () => { + const ctx = canvas.getContext('2d')!; + const spy = vi.spyOn(ctx, 'fillRect'); + engine.render(); + expect(spy).toHaveBeenCalled(); + }); + + it('should call ctx.save and restore', () => { + const ctx = canvas.getContext('2d')!; + const saveSpy = vi.spyOn(ctx, 'save'); + const restoreSpy = vi.spyOn(ctx, 'restore'); + engine.render(); + expect(saveSpy).toHaveBeenCalled(); + expect(restoreSpy).toHaveBeenCalled(); + }); + }); + + describe('render with elements', () => { + it('should render a line element (calls stroke)', () => { + const line = makeLine('l1', 50, 50, 150, 50); + spatialIndex.insert(line); + const ctx = canvas.getContext('2d')!; + const strokeSpy = vi.spyOn(ctx, 'stroke'); + engine.render(); + expect(strokeSpy).toHaveBeenCalled(); + }); + + it('should render a rect element', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + spatialIndex.insert(rect); + const ctx = canvas.getContext('2d')!; + const strokeRectSpy = vi.spyOn(ctx, 'strokeRect'); + engine.render(); + expect(strokeRectSpy).toHaveBeenCalled(); + }); + + it('should render a circle element', () => { + const circle = makeCircle('c1', 100, 100, 40); + spatialIndex.insert(circle); + const ctx = canvas.getContext('2d')!; + const arcSpy = vi.spyOn(ctx, 'arc'); + engine.render(); + expect(arcSpy).toHaveBeenCalled(); + }); + + it('should not render elements on invisible layers', () => { + const line = makeLine('l1', 50, 50, 150, 50); + spatialIndex.insert(line); + layerManager.addLayer(makeLayer('layer-1', { visible: false })); + // Replace the visible layer with invisible + layerManager.toggleVisibility('layer-1'); + const ctx = canvas.getContext('2d')!; + const strokeSpy = vi.spyOn(ctx, 'stroke'); + engine.render(); + // stroke may still be called for grid, so check that line-specific strokes are minimal + // The key assertion is that it doesn't crash + expect(strokeSpy).toHaveBeenCalled(); + }); + }); + + describe('grid rendering toggle', () => { + it('should call stroke when grid is enabled', () => { + engine.setOptions({ showGrid: true }); + const ctx = canvas.getContext('2d')!; + const strokeSpy = vi.spyOn(ctx, 'stroke'); + engine.render(); + expect(strokeSpy).toHaveBeenCalled(); + }); + + it('should render without grid when showGrid=false', () => { + engine.setOptions({ showGrid: false }); + const ctx = canvas.getContext('2d')!; + const beginPathSpy = vi.spyOn(ctx, 'beginPath'); + engine.render(); + // With no grid and no elements, beginPath should not be called for grid + // But it might be called for background. We just verify no crash. + expect(beginPathSpy).toBeDefined(); + }); + }); + + describe('snap points', () => { + it('should set snap points', () => { + engine.setSnapPoints([{ x: 10, y: 10, type: 'endpoint' }]); + engine.setOptions({ showSnapPoints: true }); + expect(() => engine.render()).not.toThrow(); + }); + + it('should set active snap point', () => { + engine.setActiveSnapPoint({ x: 10, y: 10, type: 'endpoint' }); + engine.setOptions({ showSnapPoints: true }); + expect(() => engine.render()).not.toThrow(); + }); + }); + + describe('hitTest', () => { + it('should return the element when hit within tolerance', () => { + const line = makeLine('l1', 50, 50, 150, 50); + spatialIndex.insert(line); + const hit = engine.hitTest(100, 50, 5); + expect(hit).not.toBeNull(); + expect(hit!.id).toBe('l1'); + }); + + it('should return null when no element is hit', () => { + const line = makeLine('l1', 50, 50, 150, 50); + spatialIndex.insert(line); + const hit = engine.hitTest(400, 400, 5); + expect(hit).toBeNull(); + }); + + it('should hit test a rect element', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + spatialIndex.insert(rect); + const hit = engine.hitTest(100, 100, 5); + expect(hit).not.toBeNull(); + expect(hit!.id).toBe('r1'); + }); + + it('should hit test a circle element', () => { + const circle = makeCircle('c1', 100, 100, 40); + spatialIndex.insert(circle); + const hit = engine.hitTest(140, 100, 5); + expect(hit).not.toBeNull(); + expect(hit!.id).toBe('c1'); + }); + }); + + describe('getElementBBox', () => { + it('should return correct bounding box for element', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + const bb = engine.getElementBBox(rect); + expect(bb.minX).toBe(60); + expect(bb.minY).toBe(70); + expect(bb.maxX).toBe(140); + expect(bb.maxY).toBe(130); + }); + }); + + describe('getElementsInRect', () => { + it('should return fully enclosed elements', () => { + const r1 = makeRect('r1', 100, 100, 40, 40); + const r2 = makeRect('r2', 300, 300, 40, 40); + spatialIndex.insert(r1); + spatialIndex.insert(r2); + const result = engine.getElementsInRect(50, 50, 200, 200); + expect(result.length).toBe(1); + expect(result[0].id).toBe('r1'); + }); + + it('should return empty when no elements in rect', () => { + const r1 = makeRect('r1', 100, 100, 40, 40); + spatialIndex.insert(r1); + const result = engine.getElementsInRect(500, 500, 600, 600); + expect(result.length).toBe(0); + }); + }); + + describe('getElementsIntersectingRect', () => { + it('should return intersecting elements', () => { + const r1 = makeRect('r1', 100, 100, 80, 80); + spatialIndex.insert(r1); + const result = engine.getElementsIntersectingRect(80, 80, 120, 120); + expect(result.length).toBe(1); + expect(result[0].id).toBe('r1'); + }); + }); + + describe('setLayers', () => { + it('should clear and set new layers', () => { + engine.setLayers([ + makeLayer('new-1'), + makeLayer('new-2'), + ]); + const layers = layerManager.getLayers(); + expect(layers.length).toBe(2); + }); + }); + + describe('resize', () => { + it('should resize canvas dimensions', () => { + engine.resize(400, 300); + // dpr is likely 1 in jsdom + expect(canvas.width).toBeGreaterThanOrEqual(400); + expect(canvas.height).toBeGreaterThanOrEqual(300); + }); + }); + + describe('setBlockDefinitions', () => { + it('should set block definitions without error', () => { + engine.setBlockDefinitions([{ id: 'blk-1', elements: [] }]); + expect(() => engine.render()).not.toThrow(); + }); + }); +}); diff --git a/frontend/tests/SelectionEngine.test.ts b/frontend/tests/SelectionEngine.test.ts new file mode 100644 index 0000000..76a9d34 --- /dev/null +++ b/frontend/tests/SelectionEngine.test.ts @@ -0,0 +1,394 @@ +/** + * SelectionEngine Tests – Selection modes, filters, box selection, hover, listeners + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SelectionEngine } from '../src/canvas/SelectionEngine'; +import { RenderEngine } from '../src/canvas/RenderEngine'; +import { SpatialIndex } from '../src/canvas/SpatialIndex'; +import { LayerManager } from '../src/canvas/LayerManager'; +import { ZoomPanController } from '../src/canvas/ZoomPanController'; +import type { CADElement, CADLayer } from '../src/types/cad.types'; + +function createMockCanvas(w = 800, h = 600): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.fillRect = (() => {}) as any; + ctx.strokeRect = (() => {}) as any; + ctx.beginPath = (() => {}) as any; + ctx.moveTo = (() => {}) as any; + ctx.lineTo = (() => {}) as any; + ctx.stroke = (() => {}) as any; + ctx.fill = (() => {}) as any; + ctx.save = (() => {}) as any; + ctx.restore = (() => {}) as any; + ctx.scale = (() => {}) as any; + ctx.arc = (() => {}) as any; + ctx.setLineDash = (() => {}) as any; + ctx.clearRect = (() => {}) as any; + ctx.translate = (() => {}) as any; + ctx.rotate = (() => {}) as any; + ctx.clip = (() => {}) as any; + ctx.fillText = (() => {}) as any; + ctx.quadraticCurveTo = (() => {}) as any; + ctx.closePath = (() => {}) as any; + } + return canvas; +} + +function makeLayer(id: string, overrides: Partial = {}): CADLayer { + return { + id, + name: id, + visible: true, + locked: false, + color: '#ffffff', + lineType: 'solid', + transparency: 0, + sortOrder: 0, + parentId: null, + ...overrides, + }; +} + +function makeLine(id: string, x1: number, y1: number, x2: number, y2: number, layerId = 'layer-1'): CADElement { + return { + id, + 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 }, + }; +} + +function makeRect(id: string, cx: number, cy: number, w: number, h: number, layerId = 'layer-1'): CADElement { + return { + id, + type: 'rect', + layerId, + x: cx, + y: cy, + width: w, + height: h, + properties: {}, + }; +} + +function setup(): { + canvas: HTMLCanvasElement; + zpc: ZoomPanController; + spatialIndex: SpatialIndex; + layerManager: LayerManager; + renderEngine: RenderEngine; + selectionEngine: SelectionEngine; +} { + const canvas = createMockCanvas(800, 600); + const zpc = new ZoomPanController(canvas); + const spatialIndex = new SpatialIndex(); + const layerManager = new LayerManager(); + layerManager.addLayer(makeLayer('layer-1')); + const renderEngine = new RenderEngine(canvas, zpc, spatialIndex, layerManager); + const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager); + return { canvas, zpc, spatialIndex, layerManager, renderEngine, selectionEngine }; +} + +describe('SelectionEngine', () => { + let s: ReturnType; + + beforeEach(() => { + s = setup(); + }); + + describe('initial state', () => { + it('should have empty selection', () => { + expect(s.selectionEngine.getSelectedIds().size).toBe(0); + }); + + it('should have default options (single mode, all filter)', () => { + const opts = s.selectionEngine.getOptions(); + expect(opts.mode).toBe('single'); + expect(opts.filter).toBe('all'); + expect(opts.additive).toBe(false); + expect(opts.subtractive).toBe(false); + }); + + it('should have null hover', () => { + expect(s.selectionEngine.getHoverId()).toBeNull(); + }); + }); + + describe('setOptions', () => { + it('should update options partially', () => { + s.selectionEngine.setOptions({ filter: 'lines' }); + expect(s.selectionEngine.getOptions().filter).toBe('lines'); + }); + }); + + describe('clickSelect – hit', () => { + it('should select an element when clicking on it', () => { + const line = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(line); + const result = s.selectionEngine.clickSelect(100, 50, [line]); + expect(result).not.toBeNull(); + expect(result!.id).toBe('l1'); + expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(true); + }); + + it('should select a rect element when clicking within it', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + s.spatialIndex.insert(rect); + const result = s.selectionEngine.clickSelect(100, 100, [rect]); + expect(result).not.toBeNull(); + expect(result!.id).toBe('r1'); + }); + }); + + describe('clickSelect – miss', () => { + it('should return null when clicking empty space', () => { + const line = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(line); + const result = s.selectionEngine.clickSelect(400, 400, [line]); + expect(result).toBeNull(); + expect(s.selectionEngine.getSelectedIds().size).toBe(0); + }); + + it('should clear selection when clicking empty space in non-additive mode', () => { + const line = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(line); + s.selectionEngine.clickSelect(100, 50, [line]); + expect(s.selectionEngine.getSelectedIds().size).toBe(1); + s.selectionEngine.clickSelect(400, 400, [line]); + expect(s.selectionEngine.getSelectedIds().size).toBe(0); + }); + }); + + describe('clickSelect – additive mode', () => { + it('should add to selection in additive mode', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + const l2 = makeLine('l2', 50, 100, 150, 100); + s.spatialIndex.insert(l1); + s.spatialIndex.insert(l2); + s.selectionEngine.setOptions({ additive: true }); + s.selectionEngine.clickSelect(100, 50, [l1, l2]); + s.selectionEngine.clickSelect(100, 100, [l1, l2]); + expect(s.selectionEngine.getSelectedIds().size).toBe(2); + }); + + it('should not clear on miss in additive mode', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(l1); + s.selectionEngine.setOptions({ additive: true }); + s.selectionEngine.clickSelect(100, 50, [l1]); + s.selectionEngine.clickSelect(400, 400, [l1]); + expect(s.selectionEngine.getSelectedIds().size).toBe(1); + }); + }); + + describe('clickSelect – subtractive mode', () => { + it('should remove from selection in subtractive mode', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(l1); + // First select normally + s.selectionEngine.clickSelect(100, 50, [l1]); + expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(true); + // Now subtract + s.selectionEngine.setOptions({ subtractive: true }); + s.selectionEngine.clickSelect(100, 50, [l1]); + expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(false); + }); + }); + + describe('clickSelect – filter modes', () => { + it('should not select when filter does not match', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + s.spatialIndex.insert(rect); + s.selectionEngine.setOptions({ filter: 'lines' }); + const result = s.selectionEngine.clickSelect(100, 100, [rect]); + expect(result).toBeNull(); + }); + + it('should select when filter matches lines', () => { + const line = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(line); + s.selectionEngine.setOptions({ filter: 'lines' }); + const result = s.selectionEngine.clickSelect(100, 50, [line]); + expect(result).not.toBeNull(); + expect(result!.id).toBe('l1'); + }); + + it('should select rects with rects filter', () => { + const rect = makeRect('r1', 100, 100, 80, 60); + s.spatialIndex.insert(rect); + s.selectionEngine.setOptions({ filter: 'rects' }); + const result = s.selectionEngine.clickSelect(100, 100, [rect]); + expect(result).not.toBeNull(); + }); + }); + + describe('box selection', () => { + it('should select elements within box (window mode)', () => { + const r1 = makeRect('r1', 100, 100, 40, 40); + const r2 = makeRect('r2', 300, 300, 40, 40); + s.spatialIndex.insert(r1); + s.spatialIndex.insert(r2); + // Window: left-to-right, fully enclosed + s.selectionEngine.startBoxSelect(50, 50); + s.selectionEngine.updateBoxSelect(200, 200, [r1, r2]); + const selected = s.selectionEngine.finishBoxSelect([r1, r2]); + // r1 bbox: 80,80 to 120,120 — fully within 50,50 to 200,200 + expect(selected.length).toBe(1); + expect(selected[0].id).toBe('r1'); + }); + + it('should select elements intersecting box (crossing mode)', () => { + const r1 = makeRect('r1', 100, 100, 40, 40); + s.spatialIndex.insert(r1); + // Crossing: right-to-left (start.x > end.x) + s.selectionEngine.startBoxSelect(200, 200); + s.selectionEngine.updateBoxSelect(90, 90, [r1]); + const selected = s.selectionEngine.finishBoxSelect([r1]); + expect(selected.length).toBe(1); + }); + + it('should clear box start/end after finish', () => { + s.selectionEngine.startBoxSelect(50, 50); + s.selectionEngine.updateBoxSelect(100, 100, []); + s.selectionEngine.finishBoxSelect([]); + expect(s.selectionEngine.isBoxSelecting()).toBe(false); + }); + + it('should return empty when no box started', () => { + const result = s.selectionEngine.finishBoxSelect([]); + expect(result).toEqual([]); + }); + + it('cancelBoxSelect should stop box selection', () => { + s.selectionEngine.startBoxSelect(50, 50); + s.selectionEngine.cancelBoxSelect(); + expect(s.selectionEngine.isBoxSelecting()).toBe(false); + }); + }); + + describe('clearSelection', () => { + it('should clear all selected ids', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(l1); + s.selectionEngine.clickSelect(100, 50, [l1]); + s.selectionEngine.clearSelection(); + expect(s.selectionEngine.getSelectedIds().size).toBe(0); + }); + }); + + describe('hover', () => { + it('should set and get hover id', () => { + s.selectionEngine.setHover('el-1'); + expect(s.selectionEngine.getHoverId()).toBe('el-1'); + }); + + it('should clear hover with null', () => { + s.selectionEngine.setHover('el-1'); + s.selectionEngine.setHover(null); + expect(s.selectionEngine.getHoverId()).toBeNull(); + }); + }); + + describe('selectByIds', () => { + it('should select by ids', () => { + s.selectionEngine.selectByIds(['a', 'b', 'c']); + expect(s.selectionEngine.getSelectedIds().size).toBe(3); + }); + + it('should add to selection when additive=true', () => { + s.selectionEngine.selectByIds(['a']); + s.selectionEngine.selectByIds(['b'], true); + expect(s.selectionEngine.getSelectedIds().size).toBe(2); + }); + }); + + describe('selectAll', () => { + it('should select all visible elements matching filter', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + const l2 = makeLine('l2', 50, 100, 150, 100); + s.spatialIndex.insert(l1); + s.spatialIndex.insert(l2); + s.selectionEngine.selectAll([l1, l2]); + expect(s.selectionEngine.getSelectedIds().size).toBe(2); + }); + }); + + describe('invertSelection', () => { + it('should invert current selection', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + const l2 = makeLine('l2', 50, 100, 150, 100); + s.spatialIndex.insert(l1); + s.spatialIndex.insert(l2); + s.selectionEngine.selectByIds(['l1']); + s.selectionEngine.invertSelection([l1, l2]); + const ids = s.selectionEngine.getSelectedIds(); + expect(ids.has('l1')).toBe(false); + expect(ids.has('l2')).toBe(true); + }); + }); + + describe('quickSelect', () => { + it('should select by type', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + const r1 = makeRect('r1', 100, 100, 80, 60); + const result = s.selectionEngine.quickSelect([l1, r1], { type: 'line' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('l1'); + }); + + it('should select by layerId', () => { + const l1 = makeLine('l1', 50, 50, 150, 50, 'layer-1'); + const l2 = makeLine('l2', 50, 100, 150, 100, 'layer-2'); + const result = s.selectionEngine.quickSelect([l1, l2], { layerId: 'layer-1' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('l1'); + }); + }); + + describe('listeners', () => { + it('should call listener on selection change', () => { + let called = false; + let received: CADElement[] = []; + s.selectionEngine.addListener((els) => { + called = true; + received = els; + }); + const l1 = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(l1); + s.selectionEngine.clickSelect(100, 50, [l1]); + expect(called).toBe(true); + expect(received.length).toBe(1); + expect(received[0].id).toBe('l1'); + }); + + it('should remove listener', () => { + let called = false; + const fn = (els: CADElement[]) => { called = true; }; + s.selectionEngine.addListener(fn); + s.selectionEngine.removeListener(fn); + const l1 = makeLine('l1', 50, 50, 150, 50); + s.spatialIndex.insert(l1); + s.selectionEngine.clickSelect(100, 50, [l1]); + expect(called).toBe(false); + }); + }); + + describe('getSelectedElements', () => { + it('should filter allElements by selected ids', () => { + const l1 = makeLine('l1', 50, 50, 150, 50); + const l2 = makeLine('l2', 50, 100, 150, 100); + s.selectionEngine.selectByIds(['l1']); + const result = s.selectionEngine.getSelectedElements([l1, l2]); + expect(result.length).toBe(1); + expect(result[0].id).toBe('l1'); + }); + }); +}); diff --git a/frontend/tests/SnapEngine.test.ts b/frontend/tests/SnapEngine.test.ts new file mode 100644 index 0000000..bb9e253 --- /dev/null +++ b/frontend/tests/SnapEngine.test.ts @@ -0,0 +1,340 @@ +/** + * SnapEngine Tests – Snapping modes, grid snap, polar tracking + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SnapEngine } from '../src/canvas/SnapEngine'; +import type { SnapMode, SnapConfig } from '../src/canvas/SnapEngine'; +import type { CADElement } from '../src/types/cad.types'; + +function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement { + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + return { + id, + type: 'line', + layerId: 'layer-1', + x: cx, + y: cy, + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + properties: { x1, y1, x2, y2 }, + }; +} + +function makeCircle(id: string, cx: number, cy: number, r: number): CADElement { + return { + id, + type: 'circle', + layerId: 'layer-1', + x: cx, + y: cy, + width: r * 2, + height: r * 2, + properties: { radius: r }, + }; +} + +function makeRect(id: string, cx: number, cy: number, w: number, h: number): CADElement { + return { + id, + type: 'rect', + layerId: 'layer-1', + x: cx, + y: cy, + width: w, + height: h, + properties: {}, + }; +} + +describe('SnapEngine', () => { + let engine: SnapEngine; + + beforeEach(() => { + engine = new SnapEngine(); + }); + + describe('constructor & config', () => { + it('should have default config with enabled=true', () => { + const cfg = engine.getConfig(); + expect(cfg.enabled).toBe(true); + }); + + it('should have default modes including endpoint, midpoint, center, intersection, nearest', () => { + const cfg = engine.getConfig(); + expect(cfg.modes.has('endpoint')).toBe(true); + expect(cfg.modes.has('midpoint')).toBe(true); + expect(cfg.modes.has('center')).toBe(true); + expect(cfg.modes.has('intersection')).toBe(true); + expect(cfg.modes.has('nearest')).toBe(true); + }); + + it('should accept custom config overrides', () => { + const eng = new SnapEngine({ tolerance: 25, gridSpacing: 50 }); + const cfg = eng.getConfig(); + expect(cfg.tolerance).toBe(25); + expect(cfg.gridSpacing).toBe(50); + }); + }); + + describe('setConfig & toggleMode', () => { + it('should update config partially', () => { + engine.setConfig({ tolerance: 30 }); + expect(engine.getConfig().tolerance).toBe(30); + }); + + it('should toggle mode on/off', () => { + expect(engine.getConfig().modes.has('endpoint')).toBe(true); + engine.toggleMode('endpoint'); + expect(engine.getConfig().modes.has('endpoint')).toBe(false); + engine.toggleMode('endpoint'); + expect(engine.getConfig().modes.has('endpoint')).toBe(true); + }); + + it('should toggle grid mode on', () => { + engine.toggleMode('grid'); + expect(engine.getConfig().modes.has('grid')).toBe(true); + }); + }); + + describe('snap – disabled', () => { + it('should return null point when disabled', () => { + engine.setConfig({ enabled: false }); + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(0, 0); + expect(result.point).toBeNull(); + expect(result.preview).toEqual([]); + }); + + it('should return null point when no modes are active', () => { + const eng = new SnapEngine({ + modes: new Set(), + }); + eng.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = eng.snap(0, 0); + expect(result.point).toBeNull(); + }); + }); + + describe('snap – endpoint mode', () => { + it('should snap to line endpoint when within tolerance', () => { + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(2, 2); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(0); + expect(result.point!.y).toBe(0); + expect(result.point!.type).toBe('endpoint'); + }); + + it('should snap to the second endpoint of a line', () => { + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(98, 1); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(100); + expect(result.point!.y).toBe(0); + expect(result.point!.type).toBe('endpoint'); + }); + + it('should not snap when outside tolerance', () => { + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + engine.setConfig({ tolerance: 5 }); + const result = engine.snap(50, 20); + expect(result.point).toBeNull(); + }); + + it('should snap to rect corners', () => { + engine.setElements([makeRect('r1', 50, 50, 40, 40)]); + const result = engine.snap(32, 32); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(30); + expect(result.point!.y).toBe(30); + expect(result.point!.type).toBe('endpoint'); + }); + }); + + describe('snap – midpoint mode', () => { + it('should snap to line midpoint', () => { + engine.setConfig({ modes: new Set(['midpoint']) }); + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(50, 2); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(50); + expect(result.point!.y).toBe(0); + expect(result.point!.type).toBe('midpoint'); + }); + + it('should snap to rect edge midpoints', () => { + engine.setConfig({ modes: new Set(['midpoint']) }); + engine.setElements([makeRect('r1', 50, 50, 40, 40)]); + const result = engine.snap(50, 31); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(50); + expect(result.point!.y).toBe(30); + expect(result.point!.type).toBe('midpoint'); + }); + }); + + describe('snap – center mode', () => { + it('should snap to circle center', () => { + engine.setConfig({ modes: new Set(['center']) }); + engine.setElements([makeCircle('c1', 50, 50, 30)]); + const result = engine.snap(52, 48); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(50); + expect(result.point!.y).toBe(50); + expect(result.point!.type).toBe('center'); + }); + + it('should snap to rect center', () => { + engine.setConfig({ modes: new Set(['center']) }); + engine.setElements([makeRect('r1', 50, 50, 40, 40)]); + const result = engine.snap(48, 52); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(50); + expect(result.point!.y).toBe(50); + expect(result.point!.type).toBe('center'); + }); + }); + + describe('snap – grid mode', () => { + it('should snap to nearest grid point', () => { + engine.setConfig({ + modes: new Set(['grid']), + gridSpacing: 20, + tolerance: 10, + }); + const result = engine.snap(18, 2); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(20); + expect(result.point!.y).toBe(0); + }); + + it('should snap to grid point at origin', () => { + engine.setConfig({ + modes: new Set(['grid']), + gridSpacing: 20, + tolerance: 10, + }); + const result = engine.snap(3, 3); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(0); + expect(result.point!.y).toBe(0); + }); + + it('should not snap when too far from grid point', () => { + engine.setConfig({ + modes: new Set(['grid']), + gridSpacing: 20, + tolerance: 5, + }); + const result = engine.snap(13, 13); + expect(result.point).toBeNull(); + }); + }); + + describe('snap – nearest mode', () => { + it('should snap to nearest point on a line', () => { + engine.setConfig({ modes: new Set(['nearest']) }); + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(50, 5); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBe(50); + expect(result.point!.y).toBe(0); + expect(result.point!.type).toBe('nearest'); + }); + }); + + describe('snap – intersection mode', () => { + it('should snap to intersection of two lines', () => { + engine.setConfig({ modes: new Set(['intersection']), tolerance: 10 }); + engine.setElements([ + makeLine('l1', 0, 0, 100, 100), + makeLine('l2', 0, 100, 100, 0), + ]); + const result = engine.snap(52, 50); + expect(result.point).not.toBeNull(); + expect(result.point!.x).toBeCloseTo(50, 0); + expect(result.point!.y).toBeCloseTo(50, 0); + expect(result.point!.type).toBe('intersection'); + }); + }); + + describe('snap – priority', () => { + it('should prefer endpoint over grid when both are in range', () => { + engine.setConfig({ + modes: new Set(['endpoint', 'grid']), + gridSpacing: 20, + tolerance: 10, + }); + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(2, 2); + expect(result.point).not.toBeNull(); + expect(result.point!.type).toBe('endpoint'); + }); + }); + + describe('snap – preview', () => { + it('should return preview candidates', () => { + engine.setElements([makeLine('l1', 0, 0, 100, 0)]); + const result = engine.snap(2, 2); + expect(result.preview.length).toBeGreaterThan(0); + }); + }); + + describe('polar tracking', () => { + it('should snap to 0-degree polar angle from reference point', () => { + engine.setConfig({ + polarEnabled: true, + polarAngles: [0, 90, 180, 270], + polarTolerance: 5, + modes: new Set(['nearest']), + }); + // Reference at (0,0), cursor near 0 degrees (horizontal right) at distance ~100 + const result = engine.snap(100, 5, { x: 0, y: 0 }); + expect(result.point).not.toBeNull(); + if (result.point) { + expect(result.point.y).toBeCloseTo(0, 0); + } + }); + + it('should snap to 90-degree polar angle from reference point', () => { + engine.setConfig({ + polarEnabled: true, + polarAngles: [0, 90, 180, 270], + polarTolerance: 5, + modes: new Set(['nearest']), + }); + // Reference at (0,0), cursor near 90 degrees (down) at distance ~100 + const result = engine.snap(5, 100, { x: 0, y: 0 }); + expect(result.point).not.toBeNull(); + if (result.point) { + expect(result.point.x).toBeCloseTo(0, 0); + } + }); + + it('should not polar-snap when polarEnabled is false', () => { + engine.setConfig({ + polarEnabled: false, + modes: new Set(['nearest']), + }); + engine.setElements([makeLine('l1', 0, 0, 200, 0)]); + const result = engine.snap(100, 5, { x: 0, y: 0 }); + // Should snap to nearest on line, not polar + if (result.point) { + expect(result.point.y).toBe(0); + } + }); + + it('should not polar-snap when cursor too close to reference', () => { + engine.setConfig({ + polarEnabled: true, + polarAngles: [0], + polarTolerance: 5, + modes: new Set(['nearest']), + }); + const result = engine.snap(0.5, 0.5, { x: 0, y: 0 }); + // dist < 1, so no polar snap; no elements either, so null + expect(result.point).toBeNull(); + }); + }); +}); diff --git a/frontend/tests/SpatialIndex.test.ts b/frontend/tests/SpatialIndex.test.ts new file mode 100644 index 0000000..a3715ac --- /dev/null +++ b/frontend/tests/SpatialIndex.test.ts @@ -0,0 +1,153 @@ +/** + * SpatialIndex Tests – rbush-based spatial search + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SpatialIndex } from '../src/canvas/SpatialIndex'; +import type { CADElement } from '../src/types/cad.types'; + +function makeElement(id: string, x: number, y: number, width: number, height: number): CADElement { + return { + id, + type: 'rect', + layerId: 'layer-1', + x, + y, + width, + height, + properties: {}, + }; +} + +describe('SpatialIndex', () => { + let index: SpatialIndex; + + beforeEach(() => { + index = new SpatialIndex(); + }); + + describe('insert & search', () => { + it('should find an inserted element within its bounding box', () => { + const el = makeElement('el-1', 50, 50, 20, 20); + index.insert(el); + + const results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); + expect(results.length).toBe(1); + expect(results[0].id).toBe('el-1'); + }); + + it('should not find an element outside the search viewport', () => { + const el = makeElement('el-1', 100, 100, 20, 20); + index.insert(el); + + const results = index.search({ minX: 0, minY: 0, maxX: 50, maxY: 50 }); + expect(results.length).toBe(0); + }); + + it('should find multiple elements within viewport', () => { + index.insert(makeElement('el-1', 10, 10, 10, 10)); + index.insert(makeElement('el-2', 20, 20, 10, 10)); + index.insert(makeElement('el-3', 100, 100, 10, 10)); + + const results = index.search({ minX: 0, minY: 0, maxX: 30, maxY: 30 }); + expect(results.length).toBe(2); + const ids = results.map(e => e.id).sort(); + expect(ids).toEqual(['el-1', 'el-2']); + }); + + it('should handle elements at origin', () => { + index.insert(makeElement('el-origin', 0, 0, 10, 10)); + const results = index.search({ minX: -5, minY: -5, maxX: 5, maxY: 5 }); + expect(results.length).toBe(1); + expect(results[0].id).toBe('el-origin'); + }); + }); + + describe('bulkInsert', () => { + it('should bulk insert and find all elements', () => { + const elements = [ + makeElement('bulk-1', 10, 10, 5, 5), + makeElement('bulk-2', 20, 20, 5, 5), + makeElement('bulk-3', 30, 30, 5, 5), + makeElement('bulk-4', 200, 200, 5, 5), + ]; + index.bulkInsert(elements); + + const results = index.search({ minX: 0, minY: 0, maxX: 50, maxY: 50 }); + expect(results.length).toBe(3); + }); + + it('should work with empty array', () => { + index.bulkInsert([]); + const results = index.search({ minX: 0, minY: 0, maxX: 100, maxY: 100 }); + expect(results.length).toBe(0); + }); + }); + + describe('remove', () => { + it('should remove an element so it is no longer found', () => { + const el = makeElement('el-rm', 50, 50, 10, 10); + index.insert(el); + + let results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); + expect(results.length).toBe(1); + + index.remove(el); + results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); + expect(results.length).toBe(0); + }); + + it('should remove only the specified element', () => { + const el1 = makeElement('el-keep', 50, 50, 10, 10); + const el2 = makeElement('el-rm', 50, 50, 10, 10); + index.insert(el1); + index.insert(el2); + + index.remove(el2); + const results = index.search({ minX: 40, minY: 40, maxX: 60, maxY: 60 }); + expect(results.length).toBe(1); + expect(results[0].id).toBe('el-keep'); + }); + }); + + describe('clear', () => { + it('should clear all elements from the index', () => { + index.insert(makeElement('el-1', 10, 10, 5, 5)); + index.insert(makeElement('el-2', 20, 20, 5, 5)); + index.insert(makeElement('el-3', 30, 30, 5, 5)); + + index.clear(); + const results = index.search({ minX: 0, minY: 0, maxX: 100, maxY: 100 }); + expect(results.length).toBe(0); + }); + }); + + describe('edge cases', () => { + it('should handle overlapping bounding boxes correctly', () => { + index.insert(makeElement('el-1', 25, 25, 30, 30)); // bbox: 10,10 to 40,40 + index.insert(makeElement('el-2', 30, 30, 30, 30)); // bbox: 15,15 to 45,45 + + const results = index.search({ minX: 10, minY: 10, maxX: 40, maxY: 40 }); + expect(results.length).toBe(2); + }); + + it('should handle zero-size elements', () => { + index.insert(makeElement('el-zero', 50, 50, 0, 0)); + const results = index.search({ minX: 49, minY: 49, maxX: 51, maxY: 51 }); + expect(results.length).toBe(1); + }); + + it('should handle negative coordinates', () => { + index.insert(makeElement('el-neg', -50, -50, 20, 20)); + const results = index.search({ minX: -60, minY: -60, maxX: -40, maxY: -40 }); + expect(results.length).toBe(1); + expect(results[0].id).toBe('el-neg'); + }); + + it('should search with a large viewport containing all elements', () => { + index.insert(makeElement('el-1', 10, 10, 5, 5)); + index.insert(makeElement('el-2', 1000, 1000, 5, 5)); + const results = index.search({ minX: -10000, minY: -10000, maxX: 10000, maxY: 10000 }); + expect(results.length).toBe(2); + }); + }); +}); diff --git a/frontend/tests/StressTest.test.ts b/frontend/tests/StressTest.test.ts new file mode 100644 index 0000000..e83a061 --- /dev/null +++ b/frontend/tests/StressTest.test.ts @@ -0,0 +1,173 @@ +/** + * Stresstest – 50.000 Elemente: SpatialIndex Query-Performance, + * HistoryManager Memory, Undo/Redo Performance. + */ +import { describe, it, expect } from 'vitest'; +import { SpatialIndex } from '../src/canvas/SpatialIndex'; +import { HistoryManager } from '../src/history/HistoryManager'; +import type { CADElement, CADLayer } from '../src/types/cad.types'; + +const N = 50_000; + +function generateElements(count: number): CADElement[] { + const elements: CADElement[] = []; + for (let i = 0; i < count; i++) { + elements.push({ + id: `elem-${i}`, + type: 'rect', + layerId: 'layer-1', + x: (i % 1000) * 1.5, + y: Math.floor(i / 1000) * 1.5, + width: 1, + height: 1, + properties: {}, + }); + } + return elements; +} + +function makeLayer(): CADLayer { + return { + id: 'layer-1', + name: 'Layer 1', + visible: true, + locked: false, + color: '#ffffff', + lineType: 'solid', + transparency: 0, + sortOrder: 0, + parentId: null, + }; +} + +describe('Stresstest: 50.000 Elemente', () => { + it('should bulk-insert 50k elements into SpatialIndex in under 2s', () => { + const elements = generateElements(N); + const index = new SpatialIndex(); + const start = performance.now(); + index.bulkInsert(elements); + const elapsed = performance.now() - start; + console.log(`SpatialIndex bulkInsert: ${elapsed.toFixed(1)}ms for ${N} elements`); + expect(elapsed).toBeLessThan(2000); + }); + + it('should search SpatialIndex with 50k elements in under 10ms', () => { + const elements = generateElements(N); + const index = new SpatialIndex(); + index.bulkInsert(elements); + // Search a region in the middle + const start = performance.now(); + const results = index.search({ minX: 750, minY: 37.5, maxX: 1500, maxY: 75 }); + const elapsed = performance.now() - start; + console.log(`SpatialIndex search: ${elapsed.toFixed(2)}ms, found ${results.length} elements`); + expect(elapsed).toBeLessThan(50); + expect(results.length).toBeGreaterThan(0); + }); + + it('should search a small point region with 50k elements in under 5ms', () => { + const elements = generateElements(N); + const index = new SpatialIndex(); + index.bulkInsert(elements); + const start = performance.now(); + const results = index.search({ minX: 750, minY: 37.5, maxX: 751, maxY: 38.5 }); + const elapsed = performance.now() - start; + console.log(`SpatialIndex point search: ${elapsed.toFixed(3)}ms, found ${results.length} elements`); + expect(elapsed).toBeLessThan(10); + }); + + it('should clear and re-insert 50k elements in under 2s', () => { + const elements = generateElements(N); + const index = new SpatialIndex(); + index.bulkInsert(elements); + // Modify 100 elements + for (let i = 0; i < 100; i++) { + elements[i].x += 5000; + } + const start = performance.now(); + index.clear(); + index.bulkInsert(elements); + const elapsed = performance.now() - start; + console.log(`SpatialIndex clear+reinsert: ${elapsed.toFixed(1)}ms after 100 modifications`); + expect(elapsed).toBeLessThan(2000); + }); + + it('should handle HistoryManager with 50k elements snapshot', () => { + const elements = generateElements(N); + const layers = [makeLayer()]; + const history = new HistoryManager({ maxStackSize: 50 }); + const start = performance.now(); + history.initialize({ + elements, + layers, + blocks: [], + groups: [], + bgConfig: null, + }); + const elapsed = performance.now() - start; + console.log(`HistoryManager initialize: ${elapsed.toFixed(1)}ms for ${N} elements`); + expect(elapsed).toBeLessThan(1000); + }); + + it('should push 10 history snapshots with 50k elements each', () => { + const elements = generateElements(N); + const layers = [makeLayer()]; + const history = new HistoryManager({ maxStackSize: 50 }); + history.initialize({ + elements, + layers, + blocks: [], + groups: [], + bgConfig: null, + }); + const start = performance.now(); + for (let i = 0; i < 10; i++) { + // Slightly modify elements each snapshot + elements[i * 100].x += 1; + history.pushSnapshot({ + elements: [...elements], + layers, + blocks: [], + groups: [], + bgConfig: null, + label: `Step ${i + 1}`, + }); + } + const elapsed = performance.now() - start; + console.log(`HistoryManager 10 snapshots: ${elapsed.toFixed(1)}ms for ${N} elements each`); + expect(elapsed).toBeLessThan(5000); + expect(history.getHistory().length).toBe(11); // initial + 10 + }); + + it('should undo/redo with 50k elements in under 100ms', () => { + const elements = generateElements(N); + const layers = [makeLayer()]; + const history = new HistoryManager({ maxStackSize: 50 }); + history.initialize({ + elements, + layers, + blocks: [], + groups: [], + bgConfig: null, + }); + history.pushSnapshot({ + elements: [...elements], + layers, + blocks: [], + groups: [], + bgConfig: null, + label: 'Step 1', + }); + const undoStart = performance.now(); + const undoSnap = history.undo(); + const undoTime = performance.now() - undoStart; + console.log(`Undo: ${undoTime.toFixed(2)}ms for ${N} elements`); + expect(undoSnap).not.toBeNull(); + expect(undoTime).toBeLessThan(100); + const redoStart = performance.now(); + const redoSnap = history.redo(); + const redoTime = performance.now() - redoStart; + console.log(`Redo: ${redoTime.toFixed(2)}ms for ${N} elements`); + expect(redoSnap).not.toBeNull(); + expect(redoTime).toBeLessThan(100); + }); +}); diff --git a/frontend/tests/ZoomPanController.test.ts b/frontend/tests/ZoomPanController.test.ts new file mode 100644 index 0000000..021dc5c --- /dev/null +++ b/frontend/tests/ZoomPanController.test.ts @@ -0,0 +1,180 @@ +/** + * ZoomPanController Tests – Zoom & Pan Transformation + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { ZoomPanController } from '../src/canvas/ZoomPanController'; + +function createMockCanvas(w = 800, h = 600): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.fillRect = (() => {}) as any; + ctx.strokeRect = (() => {}) as any; + ctx.beginPath = (() => {}) as any; + ctx.moveTo = (() => {}) as any; + ctx.lineTo = (() => {}) as any; + ctx.stroke = (() => {}) as any; + ctx.fill = (() => {}) as any; + ctx.save = (() => {}) as any; + ctx.restore = (() => {}) as any; + ctx.scale = (() => {}) as any; + ctx.arc = (() => {}) as any; + ctx.setLineDash = (() => {}) as any; + ctx.clearRect = (() => {}) as any; + ctx.translate = (() => {}) as any; + ctx.rotate = (() => {}) as any; + } + return canvas; +} + +describe('ZoomPanController', () => { + let canvas: HTMLCanvasElement; + let zpc: ZoomPanController; + + beforeEach(() => { + canvas = createMockCanvas(800, 600); + zpc = new ZoomPanController(canvas); + }); + + describe('initial state', () => { + it('should have scale=1, offsetX=0, offsetY=0', () => { + const t = zpc.getTransform(); + expect(t.a).toBe(1); + expect(t.d).toBe(1); + expect(t.e).toBe(0); + expect(t.f).toBe(0); + }); + + it('getScale should return 1 initially', () => { + expect(zpc.getScale()).toBe(1); + }); + }); + + describe('getViewport', () => { + it('should return viewport covering full canvas at scale=1', () => { + const vp = zpc.getViewport(); + expect(vp.minX).toBe(0); + expect(vp.minY).toBe(0); + expect(vp.maxX).toBe(800); + expect(vp.maxY).toBe(600); + }); + + it('should shrink visible area when zoomed in', () => { + zpc.zoomAt(400, 300, 2); + const vp = zpc.getViewport(); + const w = vp.maxX - vp.minX; + const h = vp.maxY - vp.minY; + expect(w).toBeCloseTo(400, 1); + expect(h).toBeCloseTo(300, 1); + }); + }); + + describe('zoomAt', () => { + it('should increase scale with factor > 1', () => { + zpc.zoomAt(100, 100, 2); + expect(zpc.getScale()).toBe(2); + }); + + it('should decrease scale with factor < 1', () => { + zpc.zoomAt(100, 100, 0.5); + expect(zpc.getScale()).toBe(0.5); + }); + + it('should keep the zoom center point stable', () => { + zpc.zoomAt(400, 300, 2); + const screen = zpc.worldToScreen(400, 300); + expect(screen.x).toBeCloseTo(400, 0); + expect(screen.y).toBeCloseTo(300, 0); + }); + + it('should clamp scale to minimum 0.01', () => { + zpc.zoomAt(0, 0, 0.001); + expect(zpc.getScale()).toBeGreaterThanOrEqual(0.01); + }); + + it('should clamp scale to maximum 100', () => { + for (let i = 0; i < 20; i++) { + zpc.zoomAt(400, 300, 10); + } + expect(zpc.getScale()).toBeLessThanOrEqual(100); + }); + }); + + describe('pan', () => { + it('should update offsetX and offsetY', () => { + zpc.pan(50, 30); + const t = zpc.getTransform(); + expect(t.e).toBe(50); + expect(t.f).toBe(30); + }); + + it('should accumulate pan calls', () => { + zpc.pan(10, 20); + zpc.pan(30, 40); + const t = zpc.getTransform(); + expect(t.e).toBe(40); + expect(t.f).toBe(60); + }); + }); + + describe('reset', () => { + it('should restore scale=1 and offset=0,0', () => { + zpc.zoomAt(100, 100, 3); + zpc.pan(50, 50); + zpc.reset(); + const t = zpc.getTransform(); + expect(t.a).toBe(1); + expect(t.d).toBe(1); + expect(t.e).toBe(0); + expect(t.f).toBe(0); + expect(zpc.getScale()).toBe(1); + }); + }); + + describe('worldToScreen & screenToWorld', () => { + it('should convert world to screen at identity transform', () => { + const s = zpc.worldToScreen(100, 200); + expect(s.x).toBe(100); + expect(s.y).toBe(200); + }); + + it('should convert world to screen with scale and offset', () => { + zpc.zoomAt(0, 0, 2); + zpc.pan(50, 50); + const s = zpc.worldToScreen(100, 100); + expect(s.x).toBe(250); + expect(s.y).toBe(250); + }); + }); + + describe('zoomFit', () => { + it('should not crash with empty elements', () => { + zpc.zoomFit([]); + expect(zpc.getScale()).toBe(1); + }); + + it('should fit elements within canvas', () => { + zpc.zoomFit([ + { x: 0, y: 0, width: 200, height: 200 }, + { x: 400, y: 400, width: 200, height: 200 }, + ]); + expect(zpc.getScale()).toBeGreaterThan(0); + expect(zpc.getScale()).toBeLessThan(100); + }); + }); + + describe('zoomToRect', () => { + it('should zoom to a given rect', () => { + zpc.zoomToRect({ minX: 0, minY: 0, maxX: 400, maxY: 300 }); + expect(zpc.getScale()).toBeGreaterThan(0); + }); + + it('should do nothing for zero-size rect', () => { + const before = zpc.getScale(); + zpc.zoomToRect({ minX: 0, minY: 0, maxX: 0, maxY: 0 }); + expect(zpc.getScale()).toBe(before); + }); + }); +}); diff --git a/frontend/tests/commandRegistry.test.ts b/frontend/tests/commandRegistry.test.ts new file mode 100644 index 0000000..d6e2a1e --- /dev/null +++ b/frontend/tests/commandRegistry.test.ts @@ -0,0 +1,259 @@ +/** + * commandRegistry Tests – Command lookup, autocomplete, categories + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { CommandRegistry } from '../src/services/commandRegistry'; +import type { CommandDefinition } from '../src/services/commandRegistry'; + +describe('CommandRegistry', () => { + let registry: CommandRegistry; + + beforeEach(() => { + registry = new CommandRegistry(); + }); + + describe('lookup by name', () => { + it('should find LINE by name', () => { + const cmd = registry.lookup('LINE'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('LINE'); + expect(cmd!.toolId).toBe('line'); + }); + + it('should find CIRCLE by name', () => { + const cmd = registry.lookup('CIRCLE'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('CIRCLE'); + }); + + it('should find RECT by name', () => { + const cmd = registry.lookup('RECT'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('RECT'); + }); + + it('should find UNDO by name', () => { + const cmd = registry.lookup('UNDO'); + expect(cmd).not.toBeNull(); + expect(cmd!.category).toBe('meta'); + }); + + it('should be case insensitive', () => { + const cmd = registry.lookup('line'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('LINE'); + }); + + it('should trim whitespace', () => { + const cmd = registry.lookup(' LINE '); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('LINE'); + }); + }); + + describe('lookup by alias', () => { + it('should find LINE by alias L', () => { + const cmd = registry.lookup('L'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('LINE'); + }); + + it('should find CIRCLE by alias C', () => { + const cmd = registry.lookup('C'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('CIRCLE'); + }); + + it('should find RECT by alias R', () => { + const cmd = registry.lookup('R'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('RECT'); + }); + + it('should find MOVE by alias M', () => { + const cmd = registry.lookup('M'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('MOVE'); + }); + + it('should find ERASE by alias E and DEL', () => { + expect(registry.lookup('E')!.name).toBe('ERASE'); + expect(registry.lookup('DEL')!.name).toBe('ERASE'); + }); + + it('should find POLYLINE by alias PL', () => { + const cmd = registry.lookup('PL'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('POLYLINE'); + }); + + it('should find German alias LINIE for LINE', () => { + const cmd = registry.lookup('LINIE'); + expect(cmd).not.toBeNull(); + expect(cmd!.name).toBe('LINE'); + }); + }); + + describe('lookup unknown command', () => { + it('should return null for unknown command', () => { + expect(registry.lookup('UNKNOWN')).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(registry.lookup('')).toBeNull(); + }); + + it('should return null for random text', () => { + expect(registry.lookup('XYZABC')).toBeNull(); + }); + }); + + describe('getToolId', () => { + it('should return toolId for LINE', () => { + expect(registry.getToolId('LINE')).toBe('line'); + }); + + it('should return toolId for CIRCLE alias C', () => { + expect(registry.getToolId('C')).toBe('circle'); + }); + + it('should return null for meta commands like UNDO', () => { + expect(registry.getToolId('UNDO')).toBeNull(); + }); + + it('should return null for unknown command', () => { + expect(registry.getToolId('UNKNOWN')).toBeNull(); + }); + }); + + describe('getLabel', () => { + it('should return label for LINE', () => { + const label = registry.getLabel('LINE'); + expect(label).not.toBeNull(); + expect(label).toContain('Linie'); + }); + + it('should return label for alias L', () => { + const label = registry.getLabel('L'); + expect(label).not.toBeNull(); + expect(label).toContain('Linie'); + }); + + it('should return null for unknown command', () => { + expect(registry.getLabel('UNKNOWN')).toBeNull(); + }); + }); + + describe('getAllCommands', () => { + it('should return all command definitions', () => { + const all = registry.getAllCommands(); + expect(all.length).toBeGreaterThan(10); + }); + + it('should include LINE, CIRCLE, RECT, MOVE, UNDO', () => { + const all = registry.getAllCommands(); + const names = all.map(c => c.name); + expect(names).toContain('LINE'); + expect(names).toContain('CIRCLE'); + expect(names).toContain('RECT'); + expect(names).toContain('MOVE'); + expect(names).toContain('UNDO'); + }); + }); + + describe('autocomplete', () => { + it('should return exact match first', () => { + const results = registry.autocomplete('L'); + expect(results.length).toBeGreaterThan(0); + // Exact alias match 'L' should be LINE (priority 1) + expect(results[0].name).toBe('LINE'); + }); + + it('should return commands starting with input', () => { + const results = registry.autocomplete('LI'); + expect(results.length).toBeGreaterThan(0); + const names = results.map(c => c.name); + expect(names).toContain('LINE'); + }); + + it('should return empty for empty input', () => { + expect(registry.autocomplete('')).toEqual([]); + }); + + it('should return max 10 results', () => { + const results = registry.autocomplete('A'); + expect(results.length).toBeLessThanOrEqual(10); + }); + + it('should match aliases', () => { + const results = registry.autocomplete('PL'); + expect(results.length).toBeGreaterThan(0); + const names = results.map(c => c.name); + expect(names).toContain('POLYLINE'); + }); + + it('should match case-insensitively', () => { + const results = registry.autocomplete('line'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].name).toBe('LINE'); + }); + }); + + describe('getAllNames', () => { + it('should return all names and aliases in uppercase', () => { + const names = registry.getAllNames(); + expect(names.length).toBeGreaterThan(10); + expect(names.every(n => n === n.toUpperCase())).toBe(true); + }); + + it('should include LINE and alias L', () => { + const names = registry.getAllNames(); + expect(names).toContain('LINE'); + expect(names).toContain('L'); + }); + }); + + describe('categories', () => { + it('should have draw category commands', () => { + const all = registry.getAllCommands(); + const draw = all.filter(c => c.category === 'draw'); + expect(draw.length).toBeGreaterThan(5); + const names = draw.map(c => c.name); + expect(names).toContain('LINE'); + expect(names).toContain('CIRCLE'); + }); + + it('should have modify category commands', () => { + const all = registry.getAllCommands(); + const modify = all.filter(c => c.category === 'modify'); + expect(modify.length).toBeGreaterThan(5); + const names = modify.map(c => c.name); + expect(names).toContain('MOVE'); + expect(names).toContain('ROTATE'); + }); + + it('should have view category commands', () => { + const all = registry.getAllCommands(); + const view = all.filter(c => c.category === 'view'); + expect(view.length).toBeGreaterThan(0); + const names = view.map(c => c.name); + expect(names).toContain('PAN'); + expect(names).toContain('ZOOM'); + }); + + it('should have meta category commands', () => { + const all = registry.getAllCommands(); + const meta = all.filter(c => c.category === 'meta'); + expect(meta.length).toBeGreaterThan(0); + const names = meta.map(c => c.name); + expect(names).toContain('UNDO'); + expect(names).toContain('REDO'); + }); + + it('should have special category commands', () => { + const all = registry.getAllCommands(); + const special = all.filter(c => c.category === 'special'); + expect(special.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/frontend/tests/geometry.test.ts b/frontend/tests/geometry.test.ts new file mode 100644 index 0000000..806d302 --- /dev/null +++ b/frontend/tests/geometry.test.ts @@ -0,0 +1,295 @@ +/** + * geometry.ts Tests – Pure transformation functions (move, rotate, scale, mirror) + */ +import { describe, it, expect } from 'vitest'; +import { + moveElement, + rotateElement, + scaleElement, + mirrorElement, + offsetElement, + getElementBBox, + distance, + angleBetween, +} from '../src/tools/modification/geometry'; +import type { CADElement } from '../src/types/cad.types'; + +function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement { + return { + id, + type: 'line', + layerId: 'layer-1', + x: (x1 + x2) / 2, + y: (y1 + y2) / 2, + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + properties: { x1, y1, x2, y2 }, + }; +} + +function makeRect(id: string, cx: number, cy: number, w: number, h: number): CADElement { + return { + id, + type: 'rect', + layerId: 'layer-1', + x: cx, + y: cy, + width: w, + height: h, + properties: {}, + }; +} + +function makeCircle(id: string, cx: number, cy: number, r: number): CADElement { + return { + id, + type: 'circle', + layerId: 'layer-1', + x: cx, + y: cy, + width: r * 2, + height: r * 2, + properties: { radius: r }, + }; +} + +function makePolyline(id: string, points: Array<{x:number;y:number}>): CADElement { + const xs = points.map(p => p.x); + const ys = points.map(p => p.y); + return { + id, + type: 'polyline', + layerId: 'layer-1', + x: (Math.min(...xs) + Math.max(...xs)) / 2, + y: (Math.min(...ys) + Math.max(...ys)) / 2, + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + properties: { points }, + }; +} + +describe('geometry – moveElement', () => { + it('should shift x and y by dx, dy', () => { + const el = makeRect('r1', 100, 100, 40, 40); + const moved = moveElement(el, 50, 30); + expect(moved.x).toBe(150); + expect(moved.y).toBe(130); + }); + + it('should shift line endpoint properties', () => { + const el = makeLine('l1', 0, 0, 100, 0); + const moved = moveElement(el, 50, 30); + expect(moved.properties.x1).toBe(50); + expect(moved.properties.y1).toBe(30); + expect(moved.properties.x2).toBe(150); + expect(moved.properties.y2).toBe(30); + }); + + it('should shift polyline points', () => { + const el = makePolyline('p1', [{ x: 0, y: 0 }, { x: 100, y: 50 }]); + const moved = moveElement(el, 10, 20); + expect(moved.properties.points![0].x).toBe(10); + expect(moved.properties.points![0].y).toBe(20); + expect(moved.properties.points![1].x).toBe(110); + expect(moved.properties.points![1].y).toBe(70); + }); + + it('should not modify the original element (immutability)', () => { + const el = makeLine('l1', 0, 0, 100, 0); + const original = JSON.parse(JSON.stringify(el)); + moveElement(el, 50, 50); + expect(el).toEqual(original); + }); +}); + +describe('geometry – rotateElement', () => { + it('should rotate element position around center', () => { + const el = makeRect('r1', 100, 0, 40, 40); + const rotated = rotateElement(el, 0, 0, 90); + expect(rotated.x).toBeCloseTo(0, 0); + expect(rotated.y).toBeCloseTo(100, 0); + }); + + it('should rotate line endpoints around center', () => { + const el = makeLine('l1', 100, 0, 200, 0); + const rotated = rotateElement(el, 0, 0, 90); + expect(rotated.properties.x1).toBeCloseTo(0, 0); + expect(rotated.properties.y1).toBeCloseTo(100, 0); + expect(rotated.properties.x2).toBeCloseTo(0, 0); + expect(rotated.properties.y2).toBeCloseTo(200, 0); + }); + + it('should rotate 180 degrees correctly', () => { + const el = makeRect('r1', 100, 0, 40, 40); + const rotated = rotateElement(el, 0, 0, 180); + expect(rotated.x).toBeCloseTo(-100, 0); + expect(rotated.y).toBeCloseTo(0, 0); + }); + + it('should rotate 360 degrees back to original position', () => { + const el = makeRect('r1', 100, 0, 40, 40); + const rotated = rotateElement(el, 0, 0, 360); + expect(rotated.x).toBeCloseTo(100, 5); + expect(rotated.y).toBeCloseTo(0, 5); + }); + + it('should swap width/height for 90-degree rotation on rect', () => { + const el = makeRect('r1', 100, 100, 80, 40); + const rotated = rotateElement(el, 100, 100, 90); + expect(rotated.width).toBe(40); + expect(rotated.height).toBe(80); + }); + + it('should add rotation to properties.rotation', () => { + const el = makeRect('r1', 100, 100, 40, 40); + el.properties.rotation = 30; + const rotated = rotateElement(el, 100, 100, 45); + expect(rotated.properties.rotation).toBe(75); + }); + + it('should not modify the original element', () => { + const el = makeLine('l1', 100, 0, 200, 0); + const original = JSON.parse(JSON.stringify(el)); + rotateElement(el, 0, 0, 90); + expect(el).toEqual(original); + }); +}); + +describe('geometry – scaleElement', () => { + it('should scale element position and dimensions', () => { + const el = makeRect('r1', 100, 100, 40, 40); + const scaled = scaleElement(el, 100, 100, 2, 2); + expect(scaled.x).toBe(100); + expect(scaled.y).toBe(100); + expect(scaled.width).toBe(80); + expect(scaled.height).toBe(80); + }); + + it('should scale element away from center', () => { + const el = makeRect('r1', 100, 0, 40, 40); + const scaled = scaleElement(el, 0, 0, 2, 2); + expect(scaled.x).toBe(200); + expect(scaled.y).toBe(0); + expect(scaled.width).toBe(80); + expect(scaled.height).toBe(80); + }); + + it('should scale line endpoints', () => { + const el = makeLine('l1', 100, 0, 200, 0); + const scaled = scaleElement(el, 0, 0, 2, 2); + expect(scaled.properties.x1).toBe(200); + expect(scaled.properties.x2).toBe(400); + }); + + it('should scale circle radius', () => { + const el = makeCircle('c1', 100, 100, 40); + const scaled = scaleElement(el, 100, 100, 2, 2); + expect(scaled.properties.radius).toBe(80); + }); + + it('should not modify the original element', () => { + const el = makeRect('r1', 100, 100, 40, 40); + const original = JSON.parse(JSON.stringify(el)); + scaleElement(el, 100, 100, 2, 2); + expect(el).toEqual(original); + }); +}); + +describe('geometry – mirrorElement', () => { + it('should mirror across a vertical line', () => { + const el = makeRect('r1', 100, 0, 40, 40); + const mirrored = mirrorElement(el, 200, 0, 200, 100); + expect(mirrored.x).toBe(300); + expect(mirrored.y).toBe(0); + }); + + it('should mirror across a horizontal line', () => { + const el = makeRect('r1', 0, 100, 40, 40); + const mirrored = mirrorElement(el, 0, 200, 100, 200); + expect(mirrored.x).toBe(0); + expect(mirrored.y).toBe(300); + }); + + it('should mirror line endpoints', () => { + const el = makeLine('l1', 0, 0, 100, 0); + const mirrored = mirrorElement(el, 50, 0, 50, 100); + expect(mirrored.properties.x1).toBe(100); + expect(mirrored.properties.x2).toBe(0); + }); + + it('should return element unchanged for zero-length mirror axis', () => { + const el = makeRect('r1', 100, 100, 40, 40); + const mirrored = mirrorElement(el, 50, 50, 50, 50); + expect(mirrored.x).toBe(100); + expect(mirrored.y).toBe(100); + }); + + it('should not modify the original element', () => { + const el = makeLine('l1', 0, 0, 100, 0); + const original = JSON.parse(JSON.stringify(el)); + mirrorElement(el, 50, 0, 50, 100); + expect(el).toEqual(original); + }); +}); + +describe('geometry – offsetElement', () => { + it('should offset a line perpendicularly', () => { + const el = makeLine('l1', 0, 0, 100, 0); + const offset = offsetElement(el, 10); + expect(offset.properties.y1).toBe(10); + expect(offset.properties.y2).toBe(10); + }); + + it('should offset a circle radius', () => { + const el = makeCircle('c1', 100, 100, 40); + const offset = offsetElement(el, 10); + expect(offset.properties.radius).toBe(50); + }); + + it('should return element for unknown type', () => { + const el = makeRect('r1', 100, 100, 40, 40); + const offset = offsetElement(el, 10); + expect(offset.x).toBe(100); + expect(offset.y).toBe(100); + }); +}); + +describe('geometry – getElementBBox', () => { + it('should return bbox for rect element', () => { + const el = makeRect('r1', 100, 100, 40, 60); + const bb = getElementBBox(el); + expect(bb.minX).toBe(80); + expect(bb.minY).toBe(70); + expect(bb.maxX).toBe(120); + expect(bb.maxY).toBe(130); + }); + + it('should return bbox for polyline element from points', () => { + const el = makePolyline('p1', [{ x: 10, y: 20 }, { x: 100, y: 80 }]); + const bb = getElementBBox(el); + expect(bb.minX).toBe(10); + expect(bb.minY).toBe(20); + expect(bb.maxX).toBe(100); + expect(bb.maxY).toBe(80); + }); +}); + +describe('geometry – distance', () => { + it('should calculate distance between two points', () => { + expect(distance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5); + }); + + it('should return 0 for same point', () => { + expect(distance({ x: 5, y: 5 }, { x: 5, y: 5 })).toBe(0); + }); +}); + +describe('geometry – angleBetween', () => { + it('should calculate angle between two points in degrees', () => { + expect(angleBetween({ x: 0, y: 0 }, { x: 100, y: 0 })).toBe(0); + }); + + it('should calculate 90-degree angle', () => { + expect(angleBetween({ x: 0, y: 0 }, { x: 0, y: 100 })).toBe(90); + }); +}); diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts new file mode 100644 index 0000000..0888088 --- /dev/null +++ b/frontend/tests/setup.ts @@ -0,0 +1,93 @@ +import '@testing-library/jest-dom'; + +// Mock Canvas 2D context for jsdom (jsdom doesn't implement CanvasRenderingContext2D) +const noop = () => {}; +const mockCtx = { + fillRect: noop, + strokeRect: noop, + clearRect: noop, + beginPath: noop, + closePath: noop, + moveTo: noop, + lineTo: noop, + arc: noop, + arcTo: noop, + rect: noop, + ellipse: noop, + quadraticCurveTo: noop, + bezierCurveTo: noop, + fill: noop, + stroke: noop, + save: noop, + restore: noop, + scale: noop, + translate: noop, + rotate: noop, + transform: noop, + setTransform: noop, + resetTransform: noop, + setLineDash: noop, + getLineDash: () => [] as number[], + clip: noop, + fillText: noop, + strokeText: noop, + measureText: () => ({ width: 0 }) as TextMetrics, + drawImage: noop, + createImageData: () => ({ width: 0, height: 0, data: new Uint8ClampedArray(0) }) as ImageData, + getImageData: () => ({ width: 0, height: 0, data: new Uint8ClampedArray(0) }) as ImageData, + putImageData: noop, + createLinearGradient: () => ({ addColorStop: noop }) as CanvasGradient, + createRadialGradient: () => ({ addColorStop: noop }) as CanvasGradient, + createPattern: () => null as unknown as CanvasPattern, + isPointInPath: () => false, + isPointInStroke: () => false, + // Properties + canvas: null as unknown as HTMLCanvasElement, + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + lineCap: 'butt' as CanvasLineCap, + lineJoin: 'miter' as CanvasLineJoin, + miterLimit: 10, + lineDashOffset: 0, + font: '10px sans-serif', + textAlign: 'start' as CanvasTextAlign, + textBaseline: 'alphabetic' as CanvasTextBaseline, + direction: 'ltr' as CanvasTextDirection, + globalAlpha: 1, + globalCompositeOperation: 'source-over' as GlobalCompositeOperation, + imageSmoothingEnabled: true, + imageSmoothingQuality: 'low' as ImageSmoothingQuality, + shadowBlur: 0, + shadowColor: 'rgba(0, 0, 0, 0)', + shadowOffsetX: 0, + shadowOffsetY: 0, + filter: 'none', +}; + +// Override getContext to return our mock for '2d' +HTMLCanvasElement.prototype.getContext = function (contextId: string) { + if (contextId === '2d') { + return mockCtx as unknown as CanvasRenderingContext2D; + } + return null; +} as typeof HTMLCanvasElement.prototype.getContext; + +// Mock getBoundingClientRect for canvas elements +const origGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; +HTMLElement.prototype.getBoundingClientRect = function () { + if (this instanceof HTMLCanvasElement) { + return { + left: 0, + top: 0, + right: this.width, + bottom: this.height, + width: this.width, + height: this.height, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; + } + return origGetBoundingClientRect.call(this); +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..b8d24c7 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "outDir": "./dist", + "baseUrl": "./src", + "paths": { "@/*": ["."] } + }, + "include": ["src"] +} diff --git a/frontend/vite.config.js b/frontend/vite.config.ts similarity index 56% rename from frontend/vite.config.js rename to frontend/vite.config.ts index fe264af..f0c5f69 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.ts @@ -5,10 +5,8 @@ export default defineConfig({ plugins: [react()], server: { proxy: { - '/api': 'http://localhost:5000', - }, - }, - preview: { - allowedHosts: ['cad.media-on.de', '.media-on.de'], - }, + '/api': 'http://localhost:3001', + '/ws': { target: 'ws://localhost:3001', ws: true } + } + } }); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..a4925e7 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + test: { + globals: true, + environment: 'jsdom', + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], + setupFiles: ['tests/setup.ts'], + coverage: { + provider: 'v8', + include: ['src/canvas/**', 'src/history/**', 'src/components/**'], + }, + }, +});