Restore JSX frontend with Login/Dashboard/Editor from 249d5fb, remove TS frontend, fix nginx port to 3001
This commit is contained in:
+10
-4
@@ -1,14 +1,20 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
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;"]
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web-cad - Web-based 2D CAD for Event Seating Plans</title>
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -22,17 +22,4 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy WebSocket requests to backend
|
||||
location /ws/ {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+392
-5151
File diff suppressed because it is too large
Load Diff
+19
-28
@@ -1,39 +1,30 @@
|
||||
{
|
||||
"name": "web-cad-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Frontend for Web CAD application",
|
||||
"main": "index.js",
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier --write ."
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.18.1",
|
||||
"fabric": "^5.3.0",
|
||||
"jspdf": "^2.5.2",
|
||||
"rbush": "^3.0.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"y-websocket": "^2.0.4",
|
||||
"yjs": "^13.6.20"
|
||||
"axios": "^1.16.1",
|
||||
"fabric": "^5.5.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"@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.4.20",
|
||||
"prettier": "^3.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^2.0.0"
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
|
||||
+173
-35
@@ -1,46 +1,184 @@
|
||||
.app-container {
|
||||
.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;
|
||||
height: 100vh;
|
||||
min-width: 1280px;
|
||||
font-family: var(--font-family);
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
#next-steps {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
.theme-toggle-button {
|
||||
position: fixed;
|
||||
bottom: var(--spacing-lg);
|
||||
right: var(--spacing-lg);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: var(--border-width) solid var(--border);
|
||||
background-color: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--font-size-lg);
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.theme-toggle-button:hover {
|
||||
background-color: var(--hover);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 1280px) {
|
||||
.app-container {
|
||||
min-width: auto;
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Editor from './pages/Editor';
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (!user) return <Navigate to="/login" />;
|
||||
return children;
|
||||
};
|
||||
|
||||
const AppContent = () => (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/editor/:id" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
|
||||
<Route path="/editor/new" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
|
||||
const App = () => (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -1,63 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import './App.css';
|
||||
import './styles/theme.css';
|
||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
|
||||
import RibbonBar from './components/RibbonBar';
|
||||
import SidePanel from './components/SidePanel';
|
||||
import CommandLine from './components/CommandLine';
|
||||
import StatusBar from './components/StatusBar';
|
||||
import CanvasArea from './components/CanvasArea';
|
||||
import { YjsDocument } from './crdt/YjsDocument';
|
||||
|
||||
function AppContent() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [activeRibbonTab, setActiveRibbonTab] = useState('draw');
|
||||
const canvasRef = useRef(null);
|
||||
const yjsDocRef = useRef<YjsDocument | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Instantiate YjsDocument and assign to ref
|
||||
yjsDocRef.current = new YjsDocument();
|
||||
|
||||
// Cleanup function to destroy the document when component unmounts
|
||||
return () => {
|
||||
if (yjsDocRef.current) {
|
||||
yjsDocRef.current.destroy();
|
||||
yjsDocRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRibbonTabChange = (tab: string) => {
|
||||
setActiveRibbonTab(tab);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`app-container ${theme}`} data-theme={theme}>
|
||||
<RibbonBar onTabChange={handleRibbonTabChange} yjsDoc={yjsDocRef} />
|
||||
<div className="main-content">
|
||||
<CanvasArea ref={canvasRef} />
|
||||
<SidePanel canvasRef={canvasRef} yjsDoc={yjsDocRef} />
|
||||
</div>
|
||||
<CommandLine />
|
||||
<StatusBar />
|
||||
<button
|
||||
className="theme-toggle-button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
||||
>
|
||||
{theme === 'dark' ? '☀️' : '🌙'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,62 +0,0 @@
|
||||
type Layer = {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
export class LayerManager {
|
||||
private layers: Layer[] = [];
|
||||
|
||||
public addLayer(layer: Layer): void {
|
||||
this.layers.push(layer);
|
||||
}
|
||||
|
||||
public removeLayer(id: string): void {
|
||||
const index = this.layers.findIndex(layer => layer.id === id);
|
||||
if (index !== -1) {
|
||||
this.layers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public updateLayer(layer: Layer): void {
|
||||
const index = this.layers.findIndex(l => l.id === layer.id);
|
||||
if (index !== -1) {
|
||||
this.layers[index] = layer;
|
||||
}
|
||||
}
|
||||
|
||||
public getLayer(id: string): Layer | undefined {
|
||||
return this.layers.find(layer => layer.id === id);
|
||||
}
|
||||
|
||||
public getLayers(): Layer[] {
|
||||
return this.layers;
|
||||
}
|
||||
|
||||
public getVisibleLayers(): Layer[] {
|
||||
return this.layers.filter(layer => layer.visible && !layer.locked);
|
||||
}
|
||||
|
||||
public setVisible(id: string, visible: boolean): void {
|
||||
const layer = this.getLayer(id);
|
||||
if (layer) {
|
||||
layer.visible = visible;
|
||||
}
|
||||
}
|
||||
|
||||
public setLocked(id: string, locked: boolean): void {
|
||||
const layer = this.getLayer(id);
|
||||
if (layer) {
|
||||
layer.locked = locked;
|
||||
}
|
||||
}
|
||||
|
||||
public moveLayer(id: string, newIndex: number): void {
|
||||
const index = this.layers.findIndex(layer => layer.id === id);
|
||||
if (index !== -1) {
|
||||
const [layer] = this.layers.splice(index, 1);
|
||||
this.layers.splice(newIndex, 0, layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import RBush from 'rbush';
|
||||
import { SpatialIndex } from './SpatialIndex';
|
||||
import { LayerManager } from './LayerManager';
|
||||
import { ZoomPanController } from './ZoomPanController';
|
||||
|
||||
type RenderElement = {
|
||||
id: string;
|
||||
type: 'line' | 'circle' | 'polyline' | 'polygon' | 'rect' | 'arc' | 'text' | 'dimension' | 'block_instance';
|
||||
layerId: string;
|
||||
bbox: { minX: number; minY: number; maxX: number; maxY: number };
|
||||
// Additional properties based on type
|
||||
};
|
||||
|
||||
type Layer = {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
export class RenderEngine {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private spatialIndex: SpatialIndex;
|
||||
private layerManager: LayerManager;
|
||||
private zoomPanController: ZoomPanController;
|
||||
private animationFrameId: number | null = null;
|
||||
private elements: RenderElement[] = [];
|
||||
private layers: Layer[] = [];
|
||||
private isRendering = false;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get 2D context');
|
||||
}
|
||||
this.ctx = ctx;
|
||||
this.spatialIndex = new SpatialIndex();
|
||||
this.layerManager = new LayerManager();
|
||||
this.zoomPanController = new ZoomPanController(canvas);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.isRendering) return;
|
||||
this.isRendering = true;
|
||||
this.render();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
this.isRendering = false;
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (!this.isRendering) return;
|
||||
|
||||
// Clear canvas
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Apply zoom/pan transforms
|
||||
const transform = this.zoomPanController.getTransform();
|
||||
this.ctx.save();
|
||||
this.ctx.transform(transform.a, transform.b, transform.c, transform.d, transform.e, transform.f);
|
||||
|
||||
// Render grid
|
||||
this.renderGrid();
|
||||
|
||||
// Get visible elements using viewport culling
|
||||
const viewport = this.zoomPanController.getViewport();
|
||||
const visibleElements = this.spatialIndex.search(viewport);
|
||||
|
||||
// Render elements by layer order
|
||||
const visibleLayers = this.layerManager.getVisibleLayers();
|
||||
for (const layer of visibleLayers) {
|
||||
const layerElements = visibleElements.filter(el => el.layerId === layer.id);
|
||||
for (const element of layerElements) {
|
||||
this.renderElement(element);
|
||||
}
|
||||
}
|
||||
|
||||
this.ctx.restore();
|
||||
|
||||
// Continue render loop
|
||||
this.animationFrameId = requestAnimationFrame(() => this.render());
|
||||
}
|
||||
|
||||
private renderGrid(): void {
|
||||
const gridSize = 50; // pixels
|
||||
const viewport = this.zoomPanController.getViewport();
|
||||
|
||||
this.ctx.strokeStyle = '#e0e0e0';
|
||||
this.ctx.lineWidth = 1;
|
||||
|
||||
// Vertical lines
|
||||
for (let x = Math.floor(viewport.minX / gridSize) * gridSize; x < viewport.maxX; x += gridSize) {
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(x, viewport.minY);
|
||||
this.ctx.lineTo(x, viewport.maxY);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
for (let y = Math.floor(viewport.minY / gridSize) * gridSize; y < viewport.maxY; y += gridSize) {
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(viewport.minX, y);
|
||||
this.ctx.lineTo(viewport.maxX, y);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
private renderElement(element: RenderElement): void {
|
||||
switch (element.type) {
|
||||
case 'line':
|
||||
this.renderLine(element);
|
||||
break;
|
||||
case 'circle':
|
||||
this.renderCircle(element);
|
||||
break;
|
||||
case 'polyline':
|
||||
this.renderPolyline(element);
|
||||
break;
|
||||
case 'polygon':
|
||||
this.renderPolygon(element);
|
||||
break;
|
||||
case 'rect':
|
||||
this.renderRect(element);
|
||||
break;
|
||||
case 'arc':
|
||||
this.renderArc(element);
|
||||
break;
|
||||
case 'text':
|
||||
this.renderText(element);
|
||||
break;
|
||||
case 'dimension':
|
||||
this.renderDimension(element);
|
||||
break;
|
||||
case 'block_instance':
|
||||
this.renderBlockInstance(element);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private renderLine(element: RenderElement): void {
|
||||
// Implementation for line rendering
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, 0); // Placeholder
|
||||
this.ctx.lineTo(100, 100); // Placeholder
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private renderCircle(element: RenderElement): void {
|
||||
// Implementation for circle rendering
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(50, 50, 30, 0, Math.PI * 2); // Placeholder
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private renderPolyline(element: RenderElement): void {
|
||||
// Implementation for polyline rendering
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, 0); // Placeholder
|
||||
this.ctx.lineTo(50, 50); // Placeholder
|
||||
this.ctx.lineTo(100, 0); // Placeholder
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private renderPolygon(element: RenderElement): void {
|
||||
// Implementation for polygon rendering
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, 0); // Placeholder
|
||||
this.ctx.lineTo(50, 50); // Placeholder
|
||||
this.ctx.lineTo(100, 0); // Placeholder
|
||||
this.ctx.closePath();
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private renderRect(element: RenderElement): void {
|
||||
// Implementation for rectangle rendering
|
||||
this.ctx.strokeRect(10, 10, 100, 50); // Placeholder
|
||||
}
|
||||
|
||||
private renderArc(element: RenderElement): void {
|
||||
// Implementation for arc rendering
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(50, 50, 30, 0, Math.PI); // Placeholder
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private renderText(element: RenderElement): void {
|
||||
// Implementation for text rendering
|
||||
this.ctx.fillText('Sample Text', 20, 20); // Placeholder
|
||||
}
|
||||
|
||||
private renderDimension(element: RenderElement): void {
|
||||
// Implementation for dimension rendering
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, 0); // Placeholder
|
||||
this.ctx.lineTo(100, 0); // Placeholder
|
||||
this.ctx.stroke();
|
||||
|
||||
// Draw dimension lines and text
|
||||
this.ctx.fillText('100', 50, -5); // Placeholder
|
||||
}
|
||||
|
||||
private renderBlockInstance(element: RenderElement): void {
|
||||
// Implementation for block instance rendering
|
||||
this.ctx.strokeRect(20, 20, 60, 40); // Placeholder
|
||||
this.ctx.fillText('BLOCK', 30, 45); // Placeholder
|
||||
}
|
||||
|
||||
public addElement(element: RenderElement): void {
|
||||
this.elements.push(element);
|
||||
this.spatialIndex.insert(element);
|
||||
}
|
||||
|
||||
public removeElement(id: string): void {
|
||||
const index = this.elements.findIndex(el => el.id === id);
|
||||
if (index !== -1) {
|
||||
const element = this.elements[index];
|
||||
this.elements.splice(index, 1);
|
||||
this.spatialIndex.remove(element);
|
||||
}
|
||||
}
|
||||
|
||||
public addLayer(layer: Layer): void {
|
||||
this.layers.push(layer);
|
||||
this.layerManager.addLayer(layer);
|
||||
}
|
||||
|
||||
public updateLayer(layer: Layer): void {
|
||||
const index = this.layers.findIndex(l => l.id === layer.id);
|
||||
if (index !== -1) {
|
||||
this.layers[index] = layer;
|
||||
this.layerManager.updateLayer(layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import RBush from 'rbush';
|
||||
|
||||
type BBox = {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
type SpatialItem = {
|
||||
id: string;
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
[key: string]: any; // Allow additional properties
|
||||
};
|
||||
|
||||
export class SpatialIndex {
|
||||
private rbush: RBush<SpatialItem>;
|
||||
|
||||
constructor() {
|
||||
this.rbush = new RBush<SpatialItem>();
|
||||
}
|
||||
|
||||
public insert(item: BBox & { id: string }): void {
|
||||
this.rbush.insert(item as SpatialItem);
|
||||
}
|
||||
|
||||
public remove(item: BBox & { id: string }): void {
|
||||
// RBush requires the exact same object for removal
|
||||
// In practice, you might need to search for the item first
|
||||
this.rbush.remove(item as SpatialItem);
|
||||
}
|
||||
|
||||
public search(bbox: BBox): SpatialItem[] {
|
||||
return this.rbush.search(bbox);
|
||||
}
|
||||
|
||||
public load(items: (BBox & { id: string })[]): void {
|
||||
this.rbush.load(items as SpatialItem[]);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.rbush.clear();
|
||||
}
|
||||
|
||||
public all(): SpatialItem[] {
|
||||
return this.rbush.all();
|
||||
}
|
||||
|
||||
public toJSON(): any {
|
||||
return this.rbush.toJSON();
|
||||
}
|
||||
|
||||
public fromJSON(data: any): void {
|
||||
this.rbush.fromJSON(data);
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
type Viewport = {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
};
|
||||
|
||||
type Transform = {
|
||||
a: number; // scale X
|
||||
b: number; // skew Y
|
||||
c: number; // skew X
|
||||
d: number; // scale Y
|
||||
e: number; // translate X
|
||||
f: number; // translate Y
|
||||
};
|
||||
|
||||
export class ZoomPanController {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private scale: number = 1;
|
||||
private translateX: number = 0;
|
||||
private translateY: number = 0;
|
||||
private isDragging: boolean = false;
|
||||
private dragStartX: number = 0;
|
||||
private dragStartY: number = 0;
|
||||
private dragStartTranslateX: number = 0;
|
||||
private dragStartTranslateY: number = 0;
|
||||
private minScale: number = 0.001;
|
||||
private maxScale: number = 1000;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas;
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.canvas.addEventListener('wheel', this.handleWheel.bind(this));
|
||||
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
||||
this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
||||
this.canvas.addEventListener('mouseleave', this.handleMouseUp.bind(this));
|
||||
}
|
||||
|
||||
private handleWheel(event: WheelEvent): void {
|
||||
event.preventDefault();
|
||||
|
||||
const zoomIntensity = 0.1;
|
||||
const delta = event.deltaY > 0 ? -1 : 1;
|
||||
const zoomFactor = Math.exp(delta * zoomIntensity);
|
||||
|
||||
// Calculate mouse position in world coordinates
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
|
||||
const worldX = (mouseX - this.translateX) / this.scale;
|
||||
const worldY = (mouseY - this.translateY) / this.scale;
|
||||
|
||||
// Apply zoom
|
||||
const newScale = this.scale * zoomFactor;
|
||||
this.scale = Math.max(this.minScale, Math.min(newScale, this.maxScale));
|
||||
|
||||
// Adjust translate to keep mouse position constant
|
||||
this.translateX = mouseX - worldX * this.scale;
|
||||
this.translateY = mouseY - worldY * this.scale;
|
||||
}
|
||||
|
||||
private handleMouseDown(event: MouseEvent): void {
|
||||
if (event.button === 0) { // Left mouse button
|
||||
this.isDragging = true;
|
||||
this.dragStartX = event.clientX;
|
||||
this.dragStartY = event.clientY;
|
||||
this.dragStartTranslateX = this.translateX;
|
||||
this.dragStartTranslateY = this.translateY;
|
||||
this.canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseMove(event: MouseEvent): void {
|
||||
if (this.isDragging) {
|
||||
const deltaX = event.clientX - this.dragStartX;
|
||||
const deltaY = event.clientY - this.dragStartY;
|
||||
|
||||
this.translateX = this.dragStartTranslateX + deltaX;
|
||||
this.translateY = this.dragStartTranslateY + deltaY;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseUp(): void {
|
||||
if (this.isDragging) {
|
||||
this.isDragging = false;
|
||||
this.canvas.style.cursor = 'default';
|
||||
}
|
||||
}
|
||||
|
||||
public getTransform(): Transform {
|
||||
return {
|
||||
a: this.scale,
|
||||
b: 0,
|
||||
c: 0,
|
||||
d: this.scale,
|
||||
e: this.translateX,
|
||||
f: this.translateY
|
||||
};
|
||||
}
|
||||
|
||||
public getViewport(): Viewport {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const minX = (0 - this.translateX) / this.scale;
|
||||
const minY = (0 - this.translateY) / this.scale;
|
||||
const maxX = (rect.width - this.translateX) / this.scale;
|
||||
const maxY = (rect.height - this.translateY) / this.scale;
|
||||
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
public setScale(scale: number): void {
|
||||
this.scale = Math.max(this.minScale, Math.min(scale, this.maxScale));
|
||||
}
|
||||
|
||||
public setTranslate(x: number, y: number): void {
|
||||
this.translateX = x;
|
||||
this.translateY = y;
|
||||
}
|
||||
|
||||
public getScale(): number {
|
||||
return this.scale;
|
||||
}
|
||||
|
||||
public getTranslate(): { x: number; y: number } {
|
||||
return { x: this.translateX, y: this.translateY };
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.scale = 1;
|
||||
this.translateX = 0;
|
||||
this.translateY = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { RenderEngine } from './RenderEngine';
|
||||
export { SpatialIndex } from './SpatialIndex';
|
||||
export { LayerManager } from './LayerManager';
|
||||
export { ZoomPanController } from './ZoomPanController';
|
||||
@@ -1,112 +1,32 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
const BlockLibrary = ({ canvasRef, yjsDoc }) => {
|
||||
const BlockLibrary = ({ canvasRef }) => {
|
||||
const [blocks, setBlocks] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [activeCategory, setActiveCategory] = useState('Alle');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedElements, setSelectedElements] = useState([]);
|
||||
const [newBlockName, setNewBlockName] = useState('');
|
||||
const [newBlockCategory, setNewBlockCategory] = useState('');
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Load blocks on component mount
|
||||
useEffect(() => {
|
||||
loadBlocks();
|
||||
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 loadBlocks = async () => {
|
||||
try {
|
||||
const res = await api.get('/blocks');
|
||||
const data = res.data || [];
|
||||
setBlocks(data);
|
||||
const filteredBlocks = activeCategory === 'Alle'
|
||||
? blocks
|
||||
: blocks.filter(b => (b.category || 'Allgemein') === activeCategory);
|
||||
|
||||
// Extract unique categories
|
||||
const cats = ['Alle', ...new Set(data.map(b => b.category || 'Allgemein'))];
|
||||
setCategories(cats);
|
||||
} catch (error) {
|
||||
console.error('Failed to load blocks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter blocks by category and search query
|
||||
const filteredBlocks = blocks.filter(block => {
|
||||
const matchesCategory = activeCategory === 'Alle' || (block.category || 'Allgemein') === activeCategory;
|
||||
const matchesSearch = block.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(block.category || '').toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
// Handle creating a new block from selected elements
|
||||
const handleCreateBlock = async () => {
|
||||
if (!newBlockName.trim() || selectedElements.length === 0) return;
|
||||
|
||||
try {
|
||||
const response = await api.post('/blocks', {
|
||||
name: newBlockName,
|
||||
category: newBlockCategory || 'Allgemein',
|
||||
elements: selectedElements
|
||||
});
|
||||
|
||||
// Add to Yjs document
|
||||
if (yjsDoc) {
|
||||
const blockId = response.data.id;
|
||||
yjsDoc.blocks.set(blockId, response.data);
|
||||
}
|
||||
|
||||
// Refresh blocks list
|
||||
loadBlocks();
|
||||
|
||||
// Reset form
|
||||
setNewBlockName('');
|
||||
setNewBlockCategory('');
|
||||
setShowCreateDialog(false);
|
||||
setSelectedElements([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to create block:', error);
|
||||
alert('Fehler beim Erstellen des Blocks');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle deleting a block
|
||||
const handleDeleteBlock = async (blockId, blockName) => {
|
||||
if (!window.confirm(`Sind Sie sicher, dass Sie den Block "${blockName}" löschen möchten?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/blocks/${blockId}`);
|
||||
|
||||
// Remove from Yjs document
|
||||
if (yjsDoc) {
|
||||
yjsDoc.blocks.delete(blockId);
|
||||
}
|
||||
|
||||
// Refresh blocks list
|
||||
loadBlocks();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete block:', error);
|
||||
alert('Fehler beim Löschen des Blocks');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle inserting a block instance on canvas
|
||||
const handleInsertBlock = (block) => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.addSvgObject(block.svg_data);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drag start for block insertion
|
||||
const handleDragStart = (e, block) => {
|
||||
e.dataTransfer.setData('text/plain', block.svg_data);
|
||||
};
|
||||
|
||||
// Handle drop on canvas
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
const svgData = e.dataTransfer.getData('text/plain');
|
||||
@@ -127,23 +47,6 @@ const BlockLibrary = ({ canvasRef, yjsDoc }) => {
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{/* Search bar */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Blöcke suchen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 4
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
|
||||
{categories.map(cat => (
|
||||
@@ -164,26 +67,6 @@ const BlockLibrary = ({ canvasRef, yjsDoc }) => {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create block button */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
fontSize: 12,
|
||||
background: '#52c41a',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+ Neuer Block
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Block grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
|
||||
{filteredBlocks.map(block => (
|
||||
@@ -191,7 +74,6 @@ const BlockLibrary = ({ canvasRef, yjsDoc }) => {
|
||||
key={block.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, block)}
|
||||
onClick={() => handleInsertBlock(block)}
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 4,
|
||||
@@ -199,7 +81,6 @@ const BlockLibrary = ({ canvasRef, yjsDoc }) => {
|
||||
background: 'white',
|
||||
cursor: 'grab',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={block.name}
|
||||
>
|
||||
@@ -210,128 +91,11 @@ const BlockLibrary = ({ canvasRef, yjsDoc }) => {
|
||||
<div style={{ fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{block.name}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteBlock(block.id, block.name);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
fontSize: 10,
|
||||
background: '#ff4d4f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredBlocks.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: '#999' }}>Keine Blöcke gefunden.</p>
|
||||
)}
|
||||
|
||||
{/* Create Block Dialog */}
|
||||
{showCreateDialog && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
padding: 20,
|
||||
borderRadius: 8,
|
||||
width: 300,
|
||||
}}>
|
||||
<h3 style={{ marginTop: 0 }}>Neuen Block erstellen</h3>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newBlockName}
|
||||
onChange={(e) => setNewBlockName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>Kategorie:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newBlockCategory}
|
||||
onChange={(e) => setNewBlockCategory(e.target.value)}
|
||||
placeholder="Allgemein"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={handleCreateBlock}
|
||||
disabled={!newBlockName.trim()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
fontSize: 12,
|
||||
background: '#1890ff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Erstellen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateDialog(false);
|
||||
setNewBlockName('');
|
||||
setNewBlockCategory('');
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
fontSize: 12,
|
||||
background: '#f0f0f0',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#999' }}>Keine Blöcke in dieser Kategorie.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,37 +43,6 @@ const CADCanvas = forwardRef(({ activeTool, setActiveTool, onCanvasReady }, ref)
|
||||
if (shape) fabricRef.current.add(shape);
|
||||
});
|
||||
fabricRef.current.renderAll();
|
||||
},
|
||||
// Get selected elements for block creation
|
||||
getSelectedElements: () => {
|
||||
if (!fabricRef.current) return [];
|
||||
const activeObject = fabricRef.current.getActiveObject();
|
||||
if (!activeObject) return [];
|
||||
|
||||
// If it's a group, return all objects in the group
|
||||
if (activeObject.type === 'group') {
|
||||
return activeObject.getObjects().map(obj => ({
|
||||
id: obj.id || Date.now() + Math.random(),
|
||||
type: obj.type,
|
||||
...obj.toObject(['id', 'type', 'left', 'top', 'width', 'height', 'radius', 'x1', 'y1', 'x2', 'y2', 'points', 'fill', 'stroke', 'strokeWidth'])
|
||||
}));
|
||||
}
|
||||
|
||||
// If it's a single object, return it
|
||||
return [{
|
||||
id: activeObject.id || Date.now() + Math.random(),
|
||||
type: activeObject.type,
|
||||
...activeObject.toObject(['id', 'type', 'left', 'top', 'width', 'height', 'radius', 'x1', 'y1', 'x2', 'y2', 'points', 'fill', 'stroke', 'strokeWidth'])
|
||||
}];
|
||||
},
|
||||
// Generate SVG data for selected elements
|
||||
generateSvgData: () => {
|
||||
if (!fabricRef.current) return '';
|
||||
const activeObject = fabricRef.current.getActiveObject();
|
||||
if (!activeObject) return '';
|
||||
|
||||
// Clone the object to avoid modifying the original
|
||||
return activeObject.toSVG();
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import './CanvasArea.css';
|
||||
import CADCanvas from './CADCanvas';
|
||||
|
||||
const CanvasArea = forwardRef((_, ref) => {
|
||||
return (
|
||||
<div className="canvas-area" role="main" aria-label="Drawing Canvas">
|
||||
<CADCanvas ref={ref} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default CanvasArea;
|
||||
@@ -1,108 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import './CommandLine.css';
|
||||
|
||||
const CommandLine: React.FC = () => {
|
||||
const [command, setCommand] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Mock command suggestions
|
||||
const commandList = [
|
||||
'LINE', 'CIRCLE', 'RECTANGLE', 'POLYGON',
|
||||
'MOVE', 'COPY', 'ROTATE', 'SCALE',
|
||||
'TRIM', 'EXTEND', 'FILLET', 'CHAMFER',
|
||||
'DIMLINEAR', 'DIMALIGNED', 'DIMRADIUS', 'DIMDIAMETER',
|
||||
'TEXT', 'MTEXT', 'INSERT', 'HATCH'
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// Focus the input when component mounts
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setCommand(value);
|
||||
|
||||
// Generate suggestions based on input
|
||||
if (value.length > 0) {
|
||||
const filtered = commandList.filter(cmd =>
|
||||
cmd.toLowerCase().startsWith(value.toLowerCase())
|
||||
);
|
||||
setSuggestions(filtered);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
setCommand(suggestion);
|
||||
setSuggestions([]);
|
||||
// Focus back to input after selection
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// In a real app, this would dispatch the command
|
||||
console.log('Command submitted:', command);
|
||||
setCommand('');
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="command-line" role="region" aria-label="Command Line">
|
||||
<form onSubmit={handleSubmit} className="command-form">
|
||||
<label htmlFor="command-input" className="command-label">
|
||||
Command:
|
||||
</label>
|
||||
<div className="command-input-container">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="command-input"
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={handleInputChange}
|
||||
className="command-input"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="command-suggestions"
|
||||
aria-describedby="command-description"
|
||||
placeholder="Enter command..."
|
||||
/>
|
||||
<button type="submit" className="command-submit">
|
||||
Enter
|
||||
</button>
|
||||
</div>
|
||||
<div id="command-description" className="sr-only">
|
||||
Type a CAD command and press Enter to execute
|
||||
</div>
|
||||
{suggestions.length > 0 && (
|
||||
<ul
|
||||
id="command-suggestions"
|
||||
className="suggestions-list"
|
||||
role="listbox"
|
||||
aria-label="Command Suggestions"
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="suggestion-item"
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
>
|
||||
{suggestion}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandLine;
|
||||
@@ -1,214 +1,30 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const LayerPanel = ({ layers, setLayers, activeLayer, setActiveLayer, yjsDoc }) => {
|
||||
const LayerPanel = ({ layers, setLayers, activeLayer, setActiveLayer }) => {
|
||||
const [newLayerName, setNewLayerName] = useState('');
|
||||
const [editingLayerId, setEditingLayerId] = useState(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [importFile, setImportFile] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const addLayer = () => {
|
||||
if (!newLayerName.trim()) return;
|
||||
|
||||
const newLayer = {
|
||||
id: Date.now().toString(),
|
||||
name: newLayerName,
|
||||
visible: true,
|
||||
locked: false,
|
||||
color: '#000000',
|
||||
lineType: 'solid',
|
||||
transparency: 0,
|
||||
sortOrder: layers.length
|
||||
};
|
||||
|
||||
// Add to Yjs document
|
||||
if (yjsDoc) {
|
||||
yjsDoc.layers.set(newLayer.id, newLayer);
|
||||
}
|
||||
|
||||
setLayers([...layers, newLayer]);
|
||||
setLayers([...layers, { name: newLayerName, visible: true, locked: false }]);
|
||||
setNewLayerName('');
|
||||
};
|
||||
|
||||
const deleteLayer = (id) => {
|
||||
const deleteLayer = (name) => {
|
||||
if (layers.length <= 1) return;
|
||||
|
||||
// Confirmation
|
||||
if (!window.confirm('Sind Sie sicher, dass Sie diesen Layer löschen möchten?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from Yjs document
|
||||
if (yjsDoc) {
|
||||
yjsDoc.layers.delete(id);
|
||||
}
|
||||
|
||||
const updatedLayers = layers.filter(l => l.id !== id);
|
||||
setLayers(updatedLayers);
|
||||
|
||||
if (activeLayer === id) {
|
||||
setActiveLayer(updatedLayers[0]?.id || null);
|
||||
}
|
||||
setLayers(layers.filter(l => l.name !== name));
|
||||
if (activeLayer === name) setActiveLayer(layers[0].name);
|
||||
};
|
||||
|
||||
const toggleVisible = (id) => {
|
||||
const updatedLayers = layers.map(l =>
|
||||
l.id === id ? { ...l, visible: !l.visible } : l
|
||||
);
|
||||
|
||||
// Update in Yjs document
|
||||
if (yjsDoc) {
|
||||
const layer = yjsDoc.layers.get(id);
|
||||
if (layer) {
|
||||
layer.visible = !layer.visible;
|
||||
yjsDoc.layers.set(id, layer);
|
||||
}
|
||||
}
|
||||
|
||||
setLayers(updatedLayers);
|
||||
const toggleVisible = (name) => {
|
||||
setLayers(layers.map(l => l.name === name ? { ...l, visible: !l.visible } : l));
|
||||
};
|
||||
|
||||
const toggleLocked = (id) => {
|
||||
const updatedLayers = layers.map(l =>
|
||||
l.id === id ? { ...l, locked: !l.locked } : l
|
||||
);
|
||||
|
||||
// Update in Yjs document
|
||||
if (yjsDoc) {
|
||||
const layer = yjsDoc.layers.get(id);
|
||||
if (layer) {
|
||||
layer.locked = !layer.locked;
|
||||
yjsDoc.layers.set(id, layer);
|
||||
}
|
||||
}
|
||||
|
||||
setLayers(updatedLayers);
|
||||
};
|
||||
|
||||
const startEditing = (id, name) => {
|
||||
setEditingLayerId(id);
|
||||
setEditingName(name);
|
||||
};
|
||||
|
||||
const saveEditing = (id) => {
|
||||
if (!editingName.trim()) return;
|
||||
|
||||
const updatedLayers = layers.map(l =>
|
||||
l.id === id ? { ...l, name: editingName } : l
|
||||
);
|
||||
|
||||
// Update in Yjs document
|
||||
if (yjsDoc) {
|
||||
const layer = yjsDoc.layers.get(id);
|
||||
if (layer) {
|
||||
layer.name = editingName;
|
||||
yjsDoc.layers.set(id, layer);
|
||||
}
|
||||
}
|
||||
|
||||
setLayers(updatedLayers);
|
||||
setEditingLayerId(null);
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingLayerId(null);
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
const setColor = (id, color) => {
|
||||
const updatedLayers = layers.map(l =>
|
||||
l.id === id ? { ...l, color } : l
|
||||
);
|
||||
|
||||
// Update in Yjs document
|
||||
if (yjsDoc) {
|
||||
const layer = yjsDoc.layers.get(id);
|
||||
if (layer) {
|
||||
layer.color = color;
|
||||
yjsDoc.layers.set(id, layer);
|
||||
}
|
||||
}
|
||||
|
||||
setLayers(updatedLayers);
|
||||
};
|
||||
|
||||
const setLineType = (id, lineType) => {
|
||||
const updatedLayers = layers.map(l =>
|
||||
l.id === id ? { ...l, lineType } : l
|
||||
);
|
||||
|
||||
// Update in Yjs document
|
||||
if (yjsDoc) {
|
||||
const layer = yjsDoc.layers.get(id);
|
||||
if (layer) {
|
||||
layer.lineType = lineType;
|
||||
yjsDoc.layers.set(id, layer);
|
||||
}
|
||||
}
|
||||
|
||||
setLayers(updatedLayers);
|
||||
};
|
||||
|
||||
const filteredLayers = layers.filter(layer =>
|
||||
layer.name.toLowerCase().includes(filterText.toLowerCase())
|
||||
);
|
||||
|
||||
const exportLayers = () => {
|
||||
const dataStr = JSON.stringify(layers, null, 2);
|
||||
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = 'layers.json';
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
linkElement.click();
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const importedLayers = JSON.parse(e.target.result);
|
||||
|
||||
// Validate imported layers
|
||||
if (Array.isArray(importedLayers)) {
|
||||
// Add to Yjs document
|
||||
if (yjsDoc) {
|
||||
importedLayers.forEach(layer => {
|
||||
yjsDoc.layers.set(layer.id, layer);
|
||||
});
|
||||
}
|
||||
|
||||
setLayers(importedLayers);
|
||||
|
||||
// Set first layer as active if none is active
|
||||
if (!activeLayer && importedLayers.length > 0) {
|
||||
setActiveLayer(importedLayers[0].id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Importieren der Layer: ' + error.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
// Reset file input
|
||||
event.target.value = '';
|
||||
const toggleLocked = (name) => {
|
||||
setLayers(layers.map(l => l.name === name ? { ...l, locked: !l.locked } : l));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0 10px 10px', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '0 10px 10px' }}>
|
||||
<div style={{ display: 'flex', gap: 5, marginBottom: 10 }}>
|
||||
<input
|
||||
type="text"
|
||||
@@ -216,138 +32,64 @@ const LayerPanel = ({ layers, setLayers, activeLayer, setActiveLayer, yjsDoc })
|
||||
value={newLayerName}
|
||||
onChange={(e) => setNewLayerName(e.target.value)}
|
||||
style={{ flex: 1, padding: 4, borderRadius: 3, border: '1px solid #ccc' }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addLayer()}
|
||||
/>
|
||||
<button onClick={addLayer} style={{ padding: '4px 8px', background: '#1890ff', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer' }}>+</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 5, marginBottom: 10 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Layer filtern..."
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
style={{ flex: 1, padding: 4, borderRadius: 3, border: '1px solid #ccc' }}
|
||||
/>
|
||||
<button onClick={exportLayers} style={{ padding: '4px 8px', background: '#52c41a', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer' }} title="Exportieren">⬇</button>
|
||||
<button onClick={handleImportClick} style={{ padding: '4px 8px', background: '#faad14', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer' }} title="Importieren">⬆</button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".json"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{filteredLayers.map((layer) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
onClick={() => setActiveLayer(layer.id)}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
marginBottom: 4,
|
||||
background: activeLayer === layer.id ? '#e6f7ff' : '#fff',
|
||||
border: activeLayer === layer.id ? '1px solid #1890ff' : '1px solid #ddd',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
backgroundColor: layer.color || '#000000',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 2
|
||||
}}
|
||||
/>
|
||||
{editingLayerId === layer.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={() => saveEditing(layer.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveEditing(layer.id);
|
||||
if (e.key === 'Escape') cancelEditing();
|
||||
}}
|
||||
autoFocus
|
||||
style={{ flex: 1, padding: 2, borderRadius: 2, border: '1px solid #1890ff' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onDoubleClick={() => startEditing(layer.id, layer.name)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{layer.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ display: 'flex', gap: 4 }}>
|
||||
<select
|
||||
value={layer.lineType || 'solid'}
|
||||
onChange={(e) => setLineType(layer.id, e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: '2px 4px',
|
||||
borderRadius: 2,
|
||||
border: '1px solid #ccc',
|
||||
fontSize: 10,
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
<option value="solid">─</option>
|
||||
<option value="dashed">╌</option>
|
||||
<option value="dotted">⋯</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleVisible(layer.id); }}
|
||||
style={{
|
||||
width: 24, height: 24, fontSize: 12,
|
||||
background: layer.visible ? '#52c41a' : '#d9d9d9',
|
||||
color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer',
|
||||
}}
|
||||
title={layer.visible ? 'Sichtbar' : 'Ausgeblendet'}
|
||||
>
|
||||
{layer.visible ? '👁' : '—'}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleLocked(layer.id); }}
|
||||
style={{
|
||||
width: 24, height: 24, fontSize: 12,
|
||||
background: layer.locked ? '#faad14' : '#d9d9d9',
|
||||
color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer',
|
||||
}}
|
||||
title={layer.locked ? 'Gesperrt' : 'Entsperrt'}
|
||||
>
|
||||
{layer.locked ? '🔒' : '🔓'}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); deleteLayer(layer.id); }}
|
||||
style={{
|
||||
width: 24, height: 24, fontSize: 12,
|
||||
background: '#ff4d4f', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer',
|
||||
visibility: layers.length > 1 ? 'visible' : 'hidden',
|
||||
}}
|
||||
title="Löschen"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, fontSize: 12, color: '#666', textAlign: 'center' }}>
|
||||
Doppelklick auf Layername zum Umbenennen
|
||||
</div>
|
||||
{layers.map((layer, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => setActiveLayer(layer.name)}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
marginBottom: 4,
|
||||
background: activeLayer === layer.name ? '#e6f7ff' : '#fff',
|
||||
border: activeLayer === layer.name ? '1px solid #1890ff' : '1px solid #ddd',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{layer.name}</span>
|
||||
<span style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleVisible(layer.name); }}
|
||||
style={{
|
||||
width: 24, height: 24, fontSize: 12,
|
||||
background: layer.visible ? '#52c41a' : '#d9d9d9',
|
||||
color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer',
|
||||
}}
|
||||
title={layer.visible ? 'Sichtbar' : 'Ausgeblendet'}
|
||||
>
|
||||
{layer.visible ? '👁' : '—'}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleLocked(layer.name); }}
|
||||
style={{
|
||||
width: 24, height: 24, fontSize: 12,
|
||||
background: layer.locked ? '#faad14' : '#d9d9d9',
|
||||
color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer',
|
||||
}}
|
||||
title={layer.locked ? 'Gesperrt' : 'Entsperrt'}
|
||||
>
|
||||
{layer.locked ? '🔒' : '🔓'}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); deleteLayer(layer.name); }}
|
||||
style={{
|
||||
width: 24, height: 24, fontSize: 12,
|
||||
background: '#ff4d4f', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer',
|
||||
visibility: layers.length > 1 ? 'visible' : 'hidden',
|
||||
}}
|
||||
title="Löschen"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import type { YjsDocument } from '../../crdt/YjsDocument';
|
||||
import {
|
||||
preparePrint,
|
||||
printPages,
|
||||
svgToDataUrl,
|
||||
DEFAULT_PRINT_SETTINGS,
|
||||
type PrintSettings,
|
||||
type PrintResult,
|
||||
type PageSize,
|
||||
type Orientation,
|
||||
type ScalePreset,
|
||||
} from '../../services/printService';
|
||||
import './PrintPreview.css';
|
||||
|
||||
interface PrintPreviewProps {
|
||||
yjsDoc: React.RefObject<YjsDocument | null>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZES: PageSize[] = ['a4', 'a3', 'a2', 'a1'];
|
||||
const SCALE_PRESETS: ScalePreset[] = ['1:50', '1:100', '1:200', 'custom'];
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const PrintPreview: React.FC<PrintPreviewProps> = ({ yjsDoc, onClose }) => {
|
||||
const [settings, setSettings] = useState<PrintSettings>({ ...DEFAULT_PRINT_SETTINGS });
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const printResult: PrintResult | null = useMemo(() => {
|
||||
const doc = yjsDoc?.current;
|
||||
if (!doc) return null;
|
||||
return preparePrint(doc, settings);
|
||||
}, [yjsDoc, settings]);
|
||||
|
||||
const updateSettings = useCallback((updates: Partial<PrintSettings>) => {
|
||||
setSettings((prev) => ({ ...prev, ...updates }));
|
||||
setCurrentPage(0);
|
||||
}, []);
|
||||
|
||||
const updateTitleBlock = useCallback((updates: Partial<NonNullable<PrintSettings['titleBlockData']>>) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
titleBlockData: { ...prev.titleBlockData!, ...updates },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handlePrint = useCallback(() => {
|
||||
if (printResult) {
|
||||
printPages(printResult);
|
||||
}
|
||||
}, [printResult]);
|
||||
|
||||
// Keyboard accessibility: Escape to close, focus trap, focus management
|
||||
useEffect(() => {
|
||||
// Store the element that had focus before the modal opened
|
||||
previouslyFocusedRef.current = document.activeElement as HTMLElement;
|
||||
|
||||
// Focus the first focusable element in the modal
|
||||
const modal = modalRef.current;
|
||||
if (modal) {
|
||||
const focusable = modal.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
if (focusable.length > 0) {
|
||||
focusable[0].focus();
|
||||
} else {
|
||||
modal.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
const modalEl = modalRef.current;
|
||||
if (!modalEl) return;
|
||||
|
||||
const focusableElements = modalEl.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
const first = focusableElements[0];
|
||||
const last = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first || !modalEl.contains(document.activeElement)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last || !modalEl.contains(document.activeElement)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
// Restore focus to the trigger element
|
||||
if (previouslyFocusedRef.current) {
|
||||
previouslyFocusedRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!printResult) {
|
||||
return (
|
||||
<div className="print-preview-overlay" ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="print-preview-title" tabIndex={-1}>
|
||||
<div className="print-preview-modal">
|
||||
<div className="print-preview-header">
|
||||
<h2 id="print-preview-title">Druckvorschau</h2>
|
||||
<button className="print-preview-close" onClick={onClose} aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div className="print-preview-empty">Kein Dokument verfügbar.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { layout, pages, bbox } = printResult;
|
||||
const page = pages[currentPage];
|
||||
const previewUrl = page ? svgToDataUrl(page.svg) : '';
|
||||
|
||||
return (
|
||||
<div className="print-preview-overlay" ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="print-preview-title" tabIndex={-1}>
|
||||
<div className="print-preview-modal">
|
||||
{/* Header */}
|
||||
<div className="print-preview-header">
|
||||
<h2 id="print-preview-title">Druckvorschau</h2>
|
||||
<button className="print-preview-close" onClick={onClose} aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="print-preview-body">
|
||||
{/* Settings sidebar */}
|
||||
<div className="print-preview-settings">
|
||||
<fieldset className="print-settings-fieldset">
|
||||
<legend>Seite</legend>
|
||||
<label className="print-settings-label">
|
||||
Format
|
||||
<select
|
||||
className="print-settings-select"
|
||||
value={settings.pageSize}
|
||||
onChange={(e) => updateSettings({ pageSize: e.target.value as PageSize })}
|
||||
>
|
||||
{PAGE_SIZES.map((s) => (
|
||||
<option key={s} value={s}>{s.toUpperCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="print-settings-label">
|
||||
Ausrichtung
|
||||
<select
|
||||
className="print-settings-select"
|
||||
value={settings.orientation}
|
||||
onChange={(e) => updateSettings({ orientation: e.target.value as Orientation })}
|
||||
>
|
||||
<option value="portrait">Hochformat</option>
|
||||
<option value="landscape">Querformat</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="print-settings-label">
|
||||
Rand (mm)
|
||||
<input
|
||||
type="number"
|
||||
className="print-settings-input"
|
||||
value={settings.margin}
|
||||
min="0"
|
||||
max="50"
|
||||
onChange={(e) => updateSettings({ margin: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="print-settings-fieldset">
|
||||
<legend>Maßstab</legend>
|
||||
<label className="print-settings-label">
|
||||
Vorwahl
|
||||
<select
|
||||
className="print-settings-select"
|
||||
value={settings.scale}
|
||||
onChange={(e) => updateSettings({ scale: e.target.value as ScalePreset })}
|
||||
>
|
||||
{SCALE_PRESETS.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{settings.scale === 'custom' && (
|
||||
<label className="print-settings-label">
|
||||
Custom (1:N)
|
||||
<input
|
||||
type="number"
|
||||
className="print-settings-input"
|
||||
value={settings.customScale}
|
||||
min="1"
|
||||
onChange={(e) => updateSettings({ customScale: parseFloat(e.target.value) || 1 })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="print-settings-fieldset">
|
||||
<legend>Optionen</legend>
|
||||
<label className="print-settings-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showBorder}
|
||||
onChange={(e) => updateSettings({ showBorder: e.target.checked })}
|
||||
/>
|
||||
Druckrand
|
||||
</label>
|
||||
<label className="print-settings-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showTitleBlock}
|
||||
onChange={(e) => updateSettings({ showTitleBlock: e.target.checked })}
|
||||
/>
|
||||
Titelblock
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{settings.showTitleBlock && settings.titleBlockData && (
|
||||
<fieldset className="print-settings-fieldset">
|
||||
<legend>Titelblock</legend>
|
||||
<label className="print-settings-label">
|
||||
Titel
|
||||
<input
|
||||
type="text"
|
||||
className="print-settings-text-input"
|
||||
value={settings.titleBlockData.title}
|
||||
onChange={(e) => updateTitleBlock({ title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="print-settings-label">
|
||||
Autor
|
||||
<input
|
||||
type="text"
|
||||
className="print-settings-text-input"
|
||||
value={settings.titleBlockData.author}
|
||||
onChange={(e) => updateTitleBlock({ author: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="print-settings-label">
|
||||
Datum
|
||||
<input
|
||||
type="text"
|
||||
className="print-settings-text-input"
|
||||
value={settings.titleBlockData.date}
|
||||
onChange={(e) => updateTitleBlock({ date: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="print-preview-info">
|
||||
<div className="print-info-row">
|
||||
<span>Zeichnung:</span>
|
||||
<span>{Math.round(bbox.width)} × {Math.round(bbox.height)} CAD</span>
|
||||
</div>
|
||||
<div className="print-info-row">
|
||||
<span>Seiten:</span>
|
||||
<span>{layout.totalPages} ({layout.cols} × {layout.rows})</span>
|
||||
</div>
|
||||
<div className="print-info-row">
|
||||
<span>Format:</span>
|
||||
<span>{layout.pageWidth} × {layout.pageHeight} mm</span>
|
||||
</div>
|
||||
<div className="print-info-row">
|
||||
<span>Maßstab:</span>
|
||||
<span>1:{layout.scaleValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
<div className="print-preview-canvas">
|
||||
{pages.length > 0 && previewUrl ? (
|
||||
<>
|
||||
<div className="print-preview-page-container">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={`Page ${currentPage + 1}`}
|
||||
className="print-preview-page-image"
|
||||
/>
|
||||
<div className="print-preview-page-label">
|
||||
Seite {currentPage + 1} / {pages.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page navigation */}
|
||||
{pages.length > 1 && (
|
||||
<div className="print-preview-nav">
|
||||
<button
|
||||
className="print-preview-nav-btn"
|
||||
onClick={() => setCurrentPage(Math.max(0, currentPage - 1))}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
← Zurück
|
||||
</button>
|
||||
<span className="print-preview-nav-info">
|
||||
{currentPage + 1} / {pages.length}
|
||||
</span>
|
||||
<button
|
||||
className="print-preview-nav-btn"
|
||||
onClick={() => setCurrentPage(Math.min(pages.length - 1, currentPage + 1))}
|
||||
disabled={currentPage === pages.length - 1}
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page grid overview */}
|
||||
{pages.length > 1 && (
|
||||
<div className="print-preview-grid">
|
||||
{pages.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`print-preview-grid-cell ${i === currentPage ? 'active' : ''}`}
|
||||
onClick={() => setCurrentPage(i)}
|
||||
title={`Seite ${i + 1} (Spalte ${p.col + 1}, Zeile ${p.row + 1})`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="print-preview-empty">Keine Elemente zum Drucken.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with actions */}
|
||||
<div className="print-preview-footer">
|
||||
<button className="print-preview-btn print-preview-btn-secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className="print-preview-btn print-preview-btn-primary"
|
||||
onClick={handlePrint}
|
||||
disabled={pages.length === 0}
|
||||
>
|
||||
🖨️ Drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrintPreview;
|
||||
@@ -1,485 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { CADLayer, CADProperties, CADElement } from '../../types/cad.types';
|
||||
import './PropertiesPanel.css';
|
||||
|
||||
// ─── Types (local-only, not duplicated from cad.types.ts) ─────────────────────
|
||||
|
||||
interface YjsDocumentLike {
|
||||
layers: {
|
||||
forEach: (cb: (layer: CADLayer, id: string) => void) => void;
|
||||
observe: (cb: () => void) => void;
|
||||
unobserve: (cb: () => void) => void;
|
||||
};
|
||||
elements: {
|
||||
toArray: () => CADElement[];
|
||||
observe: (cb: () => void) => void;
|
||||
unobserve: (cb: () => void) => void;
|
||||
};
|
||||
updateElement: (id: string, updates: Partial<CADElement>) => void;
|
||||
getElementById: (id: string) => CADElement | undefined;
|
||||
}
|
||||
|
||||
interface FabricObjectLike {
|
||||
id?: string;
|
||||
type?: string;
|
||||
left?: number;
|
||||
top?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
angle?: number;
|
||||
fill?: string | null;
|
||||
stroke?: string | null;
|
||||
strokeWidth?: number;
|
||||
strokeDashArray?: number[];
|
||||
set: (props: Record<string, unknown>) => void;
|
||||
setCoords: () => void;
|
||||
}
|
||||
|
||||
interface FabricCanvasLike {
|
||||
getActiveObject: () => FabricObjectLike | null;
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
off: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
renderAll: () => void;
|
||||
fire: (event: string, payload?: unknown) => void;
|
||||
}
|
||||
|
||||
interface CanvasRefLike {
|
||||
getCanvas?: () => FabricCanvasLike;
|
||||
getSelectedElements?: () => FabricObjectLike[];
|
||||
}
|
||||
|
||||
interface PropertiesPanelProps {
|
||||
canvasRef: React.RefObject<CanvasRefLike | null>;
|
||||
yjsDocRef: React.RefObject<YjsDocumentLike | null>;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const LINE_TYPE_DASH_MAP: Record<string, number[] | null> = {
|
||||
solid: null,
|
||||
dashed: [8, 4],
|
||||
dotted: [2, 3],
|
||||
};
|
||||
|
||||
function dashArrayToLineType(dashArray?: number[] | null): 'solid' | 'dashed' | 'dotted' {
|
||||
if (!dashArray || dashArray.length === 0) return 'solid';
|
||||
if (dashArray[0] <= 2) return 'dotted';
|
||||
return 'dashed';
|
||||
}
|
||||
|
||||
function clampNumber(value: string, min: number, max: number): number {
|
||||
const n = parseFloat(value);
|
||||
if (Number.isNaN(n)) return min;
|
||||
return Math.min(Math.max(n, min), max);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ canvasRef, yjsDocRef }) => {
|
||||
const [selectedElement, setSelectedElement] = useState<CADElement | null>(null);
|
||||
const [layers, setLayers] = useState<CADLayer[]>([]);
|
||||
const [fabricObject, setFabricObject] = useState<FabricObjectLike | null>(null);
|
||||
const fabricCanvasRef = useRef<FabricCanvasLike | null>(null);
|
||||
|
||||
// Debounce timer for Yjs updates (300ms) — no new dependency needed
|
||||
const yjsDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Clear debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (yjsDebounceRef.current) {
|
||||
clearTimeout(yjsDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Sync layers from YjsDocument ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const yjsDoc = yjsDocRef?.current;
|
||||
if (!yjsDoc) return;
|
||||
|
||||
const syncLayers = () => {
|
||||
const arr: CADLayer[] = [];
|
||||
yjsDoc.layers.forEach((layer, id) => {
|
||||
arr.push({ ...layer, id });
|
||||
});
|
||||
arr.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
setLayers(arr);
|
||||
};
|
||||
|
||||
syncLayers();
|
||||
yjsDoc.layers.observe(syncLayers);
|
||||
return () => yjsDoc.layers.unobserve(syncLayers);
|
||||
}, [yjsDocRef]);
|
||||
|
||||
// ── Sync elements from YjsDocument (for external updates) ─────────────────
|
||||
useEffect(() => {
|
||||
const yjsDoc = yjsDocRef?.current;
|
||||
if (!yjsDoc) return;
|
||||
|
||||
const syncElements = () => {
|
||||
if (selectedElement) {
|
||||
const updated = yjsDoc.getElementById(selectedElement.id);
|
||||
if (updated) {
|
||||
setSelectedElement(updated);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
yjsDoc.elements.observe(syncElements);
|
||||
return () => yjsDoc.elements.unobserve(syncElements);
|
||||
}, [yjsDocRef, selectedElement]);
|
||||
|
||||
// ── Wire fabric selection events ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const ref = canvasRef?.current;
|
||||
if (!ref || !ref.getCanvas) return;
|
||||
|
||||
const fabricCanvas = ref.getCanvas();
|
||||
if (!fabricCanvas) return;
|
||||
|
||||
fabricCanvasRef.current = fabricCanvas;
|
||||
|
||||
const handleSelection = () => {
|
||||
const active = fabricCanvas.getActiveObject();
|
||||
if (!active) {
|
||||
setSelectedElement(null);
|
||||
setFabricObject(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setFabricObject(active);
|
||||
|
||||
// Try to match with YjsDocument element by id
|
||||
const yjsDoc = yjsDocRef?.current;
|
||||
const elementId = (active as FabricObjectLike).id;
|
||||
if (elementId && yjsDoc) {
|
||||
const el = yjsDoc.getElementById(elementId);
|
||||
if (el) {
|
||||
setSelectedElement(el);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: build a transient element from fabric properties
|
||||
setSelectedElement({
|
||||
id: (active as FabricObjectLike).id ?? 'transient',
|
||||
type: (active as FabricObjectLike).type ?? 'unknown',
|
||||
layerId: 'default',
|
||||
x: active.left ?? 0,
|
||||
y: active.top ?? 0,
|
||||
width: active.width ?? 0,
|
||||
height: active.height ?? 0,
|
||||
properties: {
|
||||
fill: (active.fill as string) ?? 'transparent',
|
||||
stroke: active.stroke ?? '#000000',
|
||||
strokeWidth: active.strokeWidth ?? 1,
|
||||
rotation: active.angle ?? 0,
|
||||
lineType: dashArrayToLineType(active.strokeDashArray),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCleared = () => {
|
||||
setSelectedElement(null);
|
||||
setFabricObject(null);
|
||||
};
|
||||
|
||||
fabricCanvas.on('selection:created', handleSelection);
|
||||
fabricCanvas.on('selection:updated', handleSelection);
|
||||
fabricCanvas.on('selection:cleared', handleCleared);
|
||||
|
||||
return () => {
|
||||
fabricCanvas.off('selection:created', handleSelection);
|
||||
fabricCanvas.off('selection:updated', handleSelection);
|
||||
fabricCanvas.off('selection:cleared', handleCleared);
|
||||
};
|
||||
}, [canvasRef, yjsDocRef]);
|
||||
|
||||
// ── Update handlers (Panel → Canvas + YjsDocument) ──────────────────────────
|
||||
|
||||
const updateFabricAndYjs = useCallback(
|
||||
(updates: Partial<CADElement> & { properties?: Partial<CADProperties> }) => {
|
||||
if (!selectedElement) return;
|
||||
|
||||
// Debounce Yjs write (300ms) — only write after user pauses typing
|
||||
if (yjsDebounceRef.current) {
|
||||
clearTimeout(yjsDebounceRef.current);
|
||||
}
|
||||
const elId = selectedElement.id;
|
||||
yjsDebounceRef.current = setTimeout(() => {
|
||||
const yjsDoc = yjsDocRef?.current;
|
||||
if (yjsDoc) {
|
||||
yjsDoc.updateElement(elId, updates);
|
||||
}
|
||||
yjsDebounceRef.current = null;
|
||||
}, 300);
|
||||
|
||||
// Update fabric object directly (immediate, no debounce)
|
||||
const fabricObj = fabricObject;
|
||||
const fabricCanvas = fabricCanvasRef.current;
|
||||
if (fabricObj && fabricCanvas) {
|
||||
if (updates.x !== undefined) fabricObj.set({ left: updates.x });
|
||||
if (updates.y !== undefined) fabricObj.set({ top: updates.y });
|
||||
if (updates.width !== undefined) fabricObj.set({ width: updates.width });
|
||||
if (updates.height !== undefined) fabricObj.set({ height: updates.height });
|
||||
if (updates.properties?.rotation !== undefined)
|
||||
fabricObj.set({ angle: updates.properties.rotation });
|
||||
if (updates.properties?.fill !== undefined)
|
||||
fabricObj.set({ fill: updates.properties.fill });
|
||||
if (updates.properties?.stroke !== undefined)
|
||||
fabricObj.set({ stroke: updates.properties.stroke });
|
||||
if (updates.properties?.strokeWidth !== undefined)
|
||||
fabricObj.set({ strokeWidth: updates.properties.strokeWidth });
|
||||
if (updates.properties?.lineType !== undefined) {
|
||||
const dash = LINE_TYPE_DASH_MAP[updates.properties.lineType];
|
||||
fabricObj.set({ strokeDashArray: dash });
|
||||
}
|
||||
fabricObj.setCoords();
|
||||
fabricCanvas.renderAll();
|
||||
}
|
||||
|
||||
// Update local state (immediate, no debounce)
|
||||
setSelectedElement((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
...updates,
|
||||
properties: { ...prev.properties, ...updates.properties },
|
||||
};
|
||||
});
|
||||
},
|
||||
[selectedElement, fabricObject, yjsDocRef]
|
||||
);
|
||||
|
||||
// ── Field change handlers ──────────────────────────────────────────────────
|
||||
|
||||
const handleNumberChange = (field: 'x' | 'y' | 'width' | 'height', value: string) => {
|
||||
const n = clampNumber(value, -100000, 100000);
|
||||
updateFabricAndYjs({ [field]: n });
|
||||
};
|
||||
|
||||
const handleRotationChange = (value: string) => {
|
||||
const n = clampNumber(value, -360, 360);
|
||||
updateFabricAndYjs({ properties: { rotation: n } });
|
||||
};
|
||||
|
||||
const handleFillChange = (value: string) => {
|
||||
updateFabricAndYjs({ properties: { fill: value } });
|
||||
};
|
||||
|
||||
const handleStrokeChange = (value: string) => {
|
||||
updateFabricAndYjs({ properties: { stroke: value } });
|
||||
};
|
||||
|
||||
const handleStrokeWidthChange = (value: string) => {
|
||||
const n = clampNumber(value, 0, 100);
|
||||
updateFabricAndYjs({ properties: { strokeWidth: n } });
|
||||
};
|
||||
|
||||
const handleLineTypeChange = (value: 'solid' | 'dashed' | 'dotted') => {
|
||||
updateFabricAndYjs({ properties: { lineType: value } });
|
||||
};
|
||||
|
||||
const handleLayerChange = (value: string) => {
|
||||
updateFabricAndYjs({ layerId: value });
|
||||
};
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (!selectedElement) {
|
||||
return (
|
||||
<div className="properties-panel-empty">
|
||||
<div className="properties-panel-empty-icon" aria-hidden="true">⚙️</div>
|
||||
<p className="properties-panel-empty-text">
|
||||
Kein Element ausgewählt. Wähle ein Element auf dem Canvas, um seine Eigenschaften zu bearbeiten.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const props = selectedElement.properties;
|
||||
const fillValue = props.fill ?? 'transparent';
|
||||
const strokeValue = props.stroke ?? '#000000';
|
||||
const strokeWidthValue = props.strokeWidth ?? 1;
|
||||
const rotationValue = props.rotation ?? 0;
|
||||
const lineTypeValue = props.lineType ?? 'solid';
|
||||
|
||||
return (
|
||||
<div className="properties-panel" role="form" aria-label="Element Properties">
|
||||
{/* Element info header */}
|
||||
<div className="properties-panel-header">
|
||||
<span className="properties-panel-type-badge">{selectedElement.type}</span>
|
||||
<span className="properties-panel-id">ID: {selectedElement.id.substring(0, 12)}</span>
|
||||
</div>
|
||||
|
||||
{/* Position section */}
|
||||
<fieldset className="properties-panel-fieldset">
|
||||
<legend>Position</legend>
|
||||
<div className="properties-panel-row">
|
||||
<label className="properties-panel-label" htmlFor="prop-x">
|
||||
X
|
||||
<input
|
||||
id="prop-x"
|
||||
type="number"
|
||||
className="properties-panel-input"
|
||||
value={Math.round(selectedElement.x * 100) / 100}
|
||||
onChange={(e) => handleNumberChange('x', e.target.value)}
|
||||
step="1"
|
||||
/>
|
||||
</label>
|
||||
<label className="properties-panel-label" htmlFor="prop-y">
|
||||
Y
|
||||
<input
|
||||
id="prop-y"
|
||||
type="number"
|
||||
className="properties-panel-input"
|
||||
value={Math.round(selectedElement.y * 100) / 100}
|
||||
onChange={(e) => handleNumberChange('y', e.target.value)}
|
||||
step="1"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Size section */}
|
||||
<fieldset className="properties-panel-fieldset">
|
||||
<legend>Größe</legend>
|
||||
<div className="properties-panel-row">
|
||||
<label className="properties-panel-label" htmlFor="prop-width">
|
||||
Breite
|
||||
<input
|
||||
id="prop-width"
|
||||
type="number"
|
||||
className="properties-panel-input"
|
||||
value={Math.round(selectedElement.width * 100) / 100}
|
||||
onChange={(e) => handleNumberChange('width', e.target.value)}
|
||||
step="1"
|
||||
min="0"
|
||||
/>
|
||||
</label>
|
||||
<label className="properties-panel-label" htmlFor="prop-height">
|
||||
Höhe
|
||||
<input
|
||||
id="prop-height"
|
||||
type="number"
|
||||
className="properties-panel-input"
|
||||
value={Math.round(selectedElement.height * 100) / 100}
|
||||
onChange={(e) => handleNumberChange('height', e.target.value)}
|
||||
step="1"
|
||||
min="0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Rotation section */}
|
||||
<fieldset className="properties-panel-fieldset">
|
||||
<legend>Rotation</legend>
|
||||
<label className="properties-panel-label" htmlFor="prop-rotation">
|
||||
Winkel (Grad)
|
||||
<input
|
||||
id="prop-rotation"
|
||||
type="number"
|
||||
className="properties-panel-input"
|
||||
value={Math.round(rotationValue * 100) / 100}
|
||||
onChange={(e) => handleRotationChange(e.target.value)}
|
||||
step="1"
|
||||
min="-360"
|
||||
max="360"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{/* Appearance section */}
|
||||
<fieldset className="properties-panel-fieldset">
|
||||
<legend>Erscheinungsbild</legend>
|
||||
<label className="properties-panel-label" htmlFor="prop-fill">
|
||||
Füllfarbe
|
||||
<input
|
||||
id="prop-fill"
|
||||
type="color"
|
||||
className="properties-panel-color-input"
|
||||
value={fillValue === 'transparent' ? '#ffffff' : fillValue}
|
||||
onChange={(e) => handleFillChange(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="properties-panel-text-input"
|
||||
value={fillValue}
|
||||
onChange={(e) => handleFillChange(e.target.value)}
|
||||
placeholder="#RRGGBB oder transparent"
|
||||
/>
|
||||
</label>
|
||||
<label className="properties-panel-label" htmlFor="prop-stroke">
|
||||
Linienfarbe
|
||||
<input
|
||||
id="prop-stroke"
|
||||
type="color"
|
||||
className="properties-panel-color-input"
|
||||
value={strokeValue}
|
||||
onChange={(e) => handleStrokeChange(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="properties-panel-text-input"
|
||||
value={strokeValue}
|
||||
onChange={(e) => handleStrokeChange(e.target.value)}
|
||||
placeholder="#RRGGBB"
|
||||
/>
|
||||
</label>
|
||||
<label className="properties-panel-label" htmlFor="prop-stroke-width">
|
||||
Linienstärke
|
||||
<input
|
||||
id="prop-stroke-width"
|
||||
type="number"
|
||||
className="properties-panel-input"
|
||||
value={strokeWidthValue}
|
||||
onChange={(e) => handleStrokeWidthChange(e.target.value)}
|
||||
step="0.5"
|
||||
min="0"
|
||||
/>
|
||||
</label>
|
||||
<label className="properties-panel-label" htmlFor="prop-line-type">
|
||||
Linientyp
|
||||
<select
|
||||
id="prop-line-type"
|
||||
className="properties-panel-select"
|
||||
value={lineTypeValue}
|
||||
onChange={(e) => handleLineTypeChange(e.target.value as 'solid' | 'dashed' | 'dotted')}
|
||||
>
|
||||
<option value="solid">Solid</option>
|
||||
<option value="dashed">Dashed</option>
|
||||
<option value="dotted">Dotted</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{/* Layer assignment section */}
|
||||
<fieldset className="properties-panel-fieldset">
|
||||
<legend>Layer</legend>
|
||||
<label className="properties-panel-label" htmlFor="prop-layer">
|
||||
Zugewiesener Layer
|
||||
<select
|
||||
id="prop-layer"
|
||||
className="properties-panel-select"
|
||||
value={selectedElement.layerId}
|
||||
onChange={(e) => handleLayerChange(e.target.value)}
|
||||
>
|
||||
{layers.length === 0 ? (
|
||||
<option value="default">Default</option>
|
||||
) : (
|
||||
layers.map((layer) => (
|
||||
<option key={layer.id} value={layer.id}>
|
||||
{layer.name}{layer.locked ? ' (gesperrt)' : ''}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertiesPanel;
|
||||
@@ -1,224 +0,0 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import type { YjsDocument } from '../crdt/YjsDocument';
|
||||
import { exportDrawing, type ExportFormat } from '../services/exportService';
|
||||
import type { ExportResult } from '../types/cad.types';
|
||||
import { importDrawing, type ImportFormat, type ImportResult } from '../services/importService';
|
||||
import PrintPreview from './PrintPreview/PrintPreview';
|
||||
import './RibbonBar.css';
|
||||
|
||||
interface RibbonBarProps {
|
||||
onTabChange: (tab: string) => void;
|
||||
yjsDoc?: React.RefObject<YjsDocument | null>;
|
||||
}
|
||||
|
||||
const RibbonBar: React.FC<RibbonBarProps> = ({ onTabChange, yjsDoc }) => {
|
||||
const [activeTab, setActiveTab] = useState('draw');
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const [showImportMenu, setShowImportMenu] = useState(false);
|
||||
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||
const [showPrintPreview, setShowPrintPreview] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingImportFormat = useRef<ImportFormat>('json');
|
||||
|
||||
// Refs for dropdown wrappers (for outside-click detection)
|
||||
const exportDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const importDropdownRef = useRef<HTMLDivElement>(null);
|
||||
// Ref to track the trigger button that opened the print preview (for focus restoration)
|
||||
const printTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'draw', label: 'Draw', icon: '✏️' },
|
||||
{ id: 'modify', label: 'Modify', icon: '🔧' },
|
||||
{ id: 'annotate', label: 'Annotate', icon: '📝' },
|
||||
{ id: 'view', label: 'View', icon: '👁️' },
|
||||
{ id: 'tools', label: 'Tools', icon: '🛠️' },
|
||||
];
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId);
|
||||
onTabChange(tabId);
|
||||
};
|
||||
|
||||
const handleExport = useCallback(async (format: ExportFormat) => {
|
||||
setShowExportMenu(false);
|
||||
const doc = yjsDoc?.current;
|
||||
if (!doc) {
|
||||
setImportStatus('Error: No document available');
|
||||
setTimeout(() => setImportStatus(null), 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result: ExportResult = await exportDrawing(doc, format);
|
||||
if (!result.success) {
|
||||
setImportStatus(`Export error: ${result.error}`);
|
||||
setTimeout(() => setImportStatus(null), 5000);
|
||||
}
|
||||
} catch (e) {
|
||||
setImportStatus(`Export error: ${(e as Error).message}`);
|
||||
setTimeout(() => setImportStatus(null), 5000);
|
||||
}
|
||||
}, [yjsDoc]);
|
||||
|
||||
const triggerImport = useCallback((format: ImportFormat) => {
|
||||
setShowImportMenu(false);
|
||||
pendingImportFormat.current = format;
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.accept = format === 'json' ? '.json,application/json' : '.dxf,application/dxf';
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFileSelected = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const doc = yjsDoc?.current;
|
||||
if (!doc) {
|
||||
setImportStatus('Error: No document available');
|
||||
setTimeout(() => setImportStatus(null), 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result: ImportResult = await importDrawing(file, pendingImportFormat.current, doc);
|
||||
if (result.success) {
|
||||
setImportStatus(`Imported: ${result.elementsImported} elements, ${result.layersImported} layers`);
|
||||
} else {
|
||||
setImportStatus(`Import failed: ${result.errors.join(', ')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setImportStatus(`Import error: ${(err as Error).message}`);
|
||||
}
|
||||
setTimeout(() => setImportStatus(null), 5000);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}, [yjsDoc]);
|
||||
|
||||
// Outside-click and Escape close for dropdowns
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (showExportMenu && exportDropdownRef.current && !exportDropdownRef.current.contains(target)) {
|
||||
setShowExportMenu(false);
|
||||
}
|
||||
if (showImportMenu && importDropdownRef.current && !importDropdownRef.current.contains(target)) {
|
||||
setShowImportMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showExportMenu) {
|
||||
setShowExportMenu(false);
|
||||
}
|
||||
if (showImportMenu) {
|
||||
setShowImportMenu(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (showExportMenu || showImportMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [showExportMenu, showImportMenu]);
|
||||
|
||||
return (
|
||||
<div className="ribbon-bar" role="menubar" aria-label="CAD Functions">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`ribbon-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
aria-pressed={activeTab === tab.id}
|
||||
role="menuitemradio"
|
||||
>
|
||||
<span className="tab-icon" aria-hidden="true">{tab.icon}</span>
|
||||
<span className="tab-label">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="ribbon-divider" aria-hidden="true" />
|
||||
|
||||
<div className="ribbon-dropdown-wrapper" ref={exportDropdownRef}>
|
||||
<button
|
||||
className="ribbon-tab"
|
||||
onClick={() => setShowExportMenu(!showExportMenu)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showExportMenu}
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="tab-icon" aria-hidden="true">📤</span>
|
||||
<span className="tab-label">Export</span>
|
||||
</button>
|
||||
{showExportMenu && (
|
||||
<div className="ribbon-dropdown" role="menu">
|
||||
<button className="ribbon-dropdown-item" onClick={() => handleExport('json')} role="menuitem">JSON</button>
|
||||
<button className="ribbon-dropdown-item" onClick={() => handleExport('svg')} role="menuitem">SVG</button>
|
||||
<button className="ribbon-dropdown-item" onClick={() => handleExport('dxf')} role="menuitem">DXF</button>
|
||||
<button className="ribbon-dropdown-item" onClick={() => handleExport('pdf')} role="menuitem">PDF</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ribbon-dropdown-wrapper" ref={importDropdownRef}>
|
||||
<button
|
||||
className="ribbon-tab"
|
||||
onClick={() => setShowImportMenu(!showImportMenu)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showImportMenu}
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="tab-icon" aria-hidden="true">📥</span>
|
||||
<span className="tab-label">Import</span>
|
||||
</button>
|
||||
{showImportMenu && (
|
||||
<div className="ribbon-dropdown" role="menu">
|
||||
<button className="ribbon-dropdown-item" onClick={() => triggerImport('json')} role="menuitem">JSON</button>
|
||||
<button className="ribbon-dropdown-item" onClick={() => triggerImport('dxf')} role="menuitem">DXF</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ribbon-divider" aria-hidden="true" />
|
||||
|
||||
<button
|
||||
ref={printTriggerRef}
|
||||
className="ribbon-tab"
|
||||
onClick={() => setShowPrintPreview(true)}
|
||||
role="menuitem"
|
||||
aria-label="Print Preview"
|
||||
>
|
||||
<span className="tab-icon" aria-hidden="true">🖨️</span>
|
||||
<span className="tab-label">Print</span>
|
||||
</button>
|
||||
|
||||
{importStatus && (
|
||||
<span className="ribbon-status" role="status" aria-live="polite">{importStatus}</span>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
|
||||
{showPrintPreview && yjsDoc && (
|
||||
<PrintPreview yjsDoc={yjsDoc} onClose={() => {
|
||||
setShowPrintPreview(false);
|
||||
// Restore focus to the Print trigger button
|
||||
if (printTriggerRef.current) {
|
||||
printTriggerRef.current.focus();
|
||||
}
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RibbonBar;
|
||||
@@ -1,128 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import './SidePanel.css';
|
||||
import LayerPanel from './LayerPanel';
|
||||
import BlockLibrary from './BlockLibrary';
|
||||
import PropertiesPanel from './PropertiesPanel/PropertiesPanel';
|
||||
|
||||
interface SidePanelProps {
|
||||
canvasRef?: React.RefObject<unknown>;
|
||||
yjsDoc?: React.RefObject<unknown>;
|
||||
}
|
||||
|
||||
const SidePanel: React.FC<SidePanelProps> = ({ canvasRef, yjsDoc }) => {
|
||||
const [activeTab, setActiveTab] = useState('blocks');
|
||||
const [layers, setLayers] = useState<unknown[]>([]);
|
||||
const [activeLayer, setActiveLayer] = useState('');
|
||||
const localCanvasRef = useRef(null);
|
||||
const localYjsDocRef = useRef(null);
|
||||
|
||||
// Use props if provided, otherwise fall back to local refs
|
||||
const effectiveCanvasRef = (canvasRef ?? localCanvasRef) as React.RefObject<unknown>;
|
||||
const effectiveYjsDocRef = (yjsDoc ?? localYjsDocRef) as React.RefObject<unknown>;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'blocks', label: 'Blocks', icon: '🧱' },
|
||||
{ id: 'layers', label: 'Layers', icon: '📄' },
|
||||
{ id: 'properties', label: 'Properties', icon: '⚙️' },
|
||||
{ id: 'copilot', label: 'KI Copilot', icon: '🤖' },
|
||||
];
|
||||
|
||||
// Observe changes in yjsDoc.layers
|
||||
useEffect(() => {
|
||||
const yjsDocInstance = effectiveYjsDocRef?.current as {
|
||||
layers?: {
|
||||
forEach: (cb: (layer: Record<string, unknown>, id: string) => void) => void;
|
||||
observe: (cb: () => void) => void;
|
||||
unobserve: (cb: () => void) => void;
|
||||
};
|
||||
} | null;
|
||||
const yjsLayers = yjsDocInstance?.layers;
|
||||
if (!yjsLayers) return;
|
||||
|
||||
// Function to update layers from yjsDoc
|
||||
const updateLayers = () => {
|
||||
const layersArray: Array<Record<string, unknown> & { id: string }> = [];
|
||||
yjsLayers.forEach((layer, id) => {
|
||||
layersArray.push({ ...layer, id });
|
||||
});
|
||||
setLayers(layersArray);
|
||||
|
||||
// Set active layer if none is set or if current active layer doesn't exist
|
||||
if (!activeLayer || !layersArray.some(layer => layer.id === activeLayer)) {
|
||||
if (layersArray.length > 0) {
|
||||
setActiveLayer(layersArray[0].id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial update
|
||||
updateLayers();
|
||||
|
||||
// Observe changes
|
||||
yjsLayers.observe(updateLayers);
|
||||
|
||||
// Cleanup observer on unmount
|
||||
return () => {
|
||||
yjsLayers.unobserve(updateLayers);
|
||||
};
|
||||
}, [activeLayer, effectiveYjsDocRef]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'blocks':
|
||||
return <BlockLibrary canvasRef={effectiveCanvasRef as never} yjsDoc={(effectiveYjsDocRef as React.MutableRefObject<unknown>).current} />;
|
||||
case 'layers':
|
||||
return (
|
||||
<LayerPanel
|
||||
layers={layers as never}
|
||||
setLayers={setLayers as never}
|
||||
activeLayer={activeLayer}
|
||||
setActiveLayer={setActiveLayer}
|
||||
yjsDoc={(effectiveYjsDocRef as React.MutableRefObject<unknown>).current}
|
||||
/>
|
||||
);
|
||||
case 'properties':
|
||||
return (
|
||||
<PropertiesPanel
|
||||
canvasRef={effectiveCanvasRef as never}
|
||||
yjsDocRef={effectiveYjsDocRef as never}
|
||||
/>
|
||||
);
|
||||
case 'copilot':
|
||||
return <div className="panel-content">KI Copilot content goes here</div>;
|
||||
default:
|
||||
return <div className="panel-content">Select a tab</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="side-panel" role="complementary" aria-label="Properties Panel">
|
||||
<div className="panel-tabs" role="tablist" aria-label="Panel Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`panel-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
id={`tab-${tab.id}`}
|
||||
>
|
||||
<span className="tab-icon" aria-hidden="true">{tab.icon}</span>
|
||||
<span className="tab-label">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="panel-content-container"
|
||||
role="tabpanel"
|
||||
id={`panel-${activeTab}`}
|
||||
aria-labelledby={`tab-${activeTab}`}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidePanel;
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import './StatusBar.css';
|
||||
|
||||
const StatusBar: React.FC = () => {
|
||||
// Mock status data
|
||||
const coordinates = { x: 10.5, y: 20.3 };
|
||||
const snapEnabled = true;
|
||||
const activeLayer = 'Layer 1';
|
||||
const units = 'mm';
|
||||
|
||||
return (
|
||||
<div className="status-bar" role="status" aria-label="Application Status">
|
||||
<div className="status-section coordinates">
|
||||
<span className="status-label">Coordinates:</span>
|
||||
<span className="status-value">X: {coordinates.x}, Y: {coordinates.y}</span>
|
||||
</div>
|
||||
<div className="status-section snap">
|
||||
<span className="status-label">Snap:</span>
|
||||
<button
|
||||
className={`toggle-button ${snapEnabled ? 'enabled' : 'disabled'}`}
|
||||
aria-pressed={snapEnabled}
|
||||
aria-label={snapEnabled ? 'Snap enabled. Click to disable' : 'Snap disabled. Click to enable'}
|
||||
>
|
||||
{snapEnabled ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="status-section layer">
|
||||
<span className="status-label">Layer:</span>
|
||||
<span className="status-value">{activeLayer}</span>
|
||||
</div>
|
||||
<div className="status-section units">
|
||||
<span className="status-label">Units:</span>
|
||||
<span className="status-value">{units}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBar;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
api.get('/auth/me').then((res) => {
|
||||
setUser(res.data);
|
||||
}).catch(() => {
|
||||
localStorage.removeItem('token');
|
||||
}).finally(() => setLoading(false));
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (email, password) => {
|
||||
const res = await api.post('/auth/login', { email, password });
|
||||
const { token, user } = res.data;
|
||||
localStorage.setItem('token', token);
|
||||
setUser(user);
|
||||
return user;
|
||||
};
|
||||
|
||||
const register = async (email, password) => {
|
||||
const res = await api.post('/auth/register', { email, password });
|
||||
const { token, user } = res.data;
|
||||
localStorage.setItem('token', token);
|
||||
setUser(user);
|
||||
return user;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: 'light' | 'dark';
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = (): ThemeContextType => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Awareness } from 'y-protocols/awareness';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export class AwarenessManager {
|
||||
awareness: Awareness;
|
||||
|
||||
constructor(doc: Y.Doc) {
|
||||
this.awareness = new Awareness(doc);
|
||||
}
|
||||
|
||||
setLocalState(state: Record<string, any>) {
|
||||
this.awareness.setLocalState(state);
|
||||
}
|
||||
|
||||
getStates(): Map<number, Record<string, any>> {
|
||||
return this.awareness.getStates();
|
||||
}
|
||||
|
||||
on(event: 'change', callback: (updates: AwarenessUpdate) => void): void;
|
||||
on(event: 'update', callback: (updates: AwarenessUpdate) => void): void;
|
||||
on(event: string, callback: (updates: AwarenessUpdate) => void): void {
|
||||
this.awareness.on(event as any, callback);
|
||||
}
|
||||
|
||||
off(event: 'change', callback: (updates: AwarenessUpdate) => void): void;
|
||||
off(event: 'update', callback: (updates: AwarenessUpdate) => void): void;
|
||||
off(event: string, callback: (updates: AwarenessUpdate) => void): void {
|
||||
this.awareness.off(event as any, callback);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.awareness.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
type AwarenessUpdate = {
|
||||
added: number[];
|
||||
updated: number[];
|
||||
removed: number[];
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { WebsocketProvider } from 'y-websocket';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export class WebSocketProvider {
|
||||
provider: WebsocketProvider;
|
||||
|
||||
constructor(doc: Y.Doc, projectId: string, jwtToken: string) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${window.location.host}/ws/collab`;
|
||||
this.provider = new WebsocketProvider(url, `project:${projectId}`, doc, {
|
||||
params: { token: jwtToken }
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.provider.disconnect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.provider.connect();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.provider.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
type ProjectMeta = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type Layer = {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
color?: string;
|
||||
lineType?: string;
|
||||
transparency?: number;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
type Element = {
|
||||
id: string;
|
||||
type: string;
|
||||
layerId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
properties: Record<string, any>;
|
||||
};
|
||||
|
||||
type Block = {
|
||||
id: string;
|
||||
name: string;
|
||||
elements: string[]; // element IDs
|
||||
};
|
||||
|
||||
type BlockInstance = {
|
||||
id: string;
|
||||
blockId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
properties: Record<string, any>;
|
||||
};
|
||||
|
||||
export class YjsDocument {
|
||||
doc: Y.Doc;
|
||||
projectMeta: Y.Map<any>;
|
||||
layers: Y.Map<Layer>;
|
||||
elements: Y.Array<Element>;
|
||||
blocks: Y.Map<Block>;
|
||||
blockInstances: Y.Array<BlockInstance>;
|
||||
|
||||
constructor() {
|
||||
this.doc = new Y.Doc();
|
||||
this.projectMeta = this.doc.getMap('projectMeta');
|
||||
this.layers = this.doc.getMap('layers');
|
||||
this.elements = this.doc.getArray('elements');
|
||||
this.blocks = this.doc.getMap('blocks');
|
||||
this.blockInstances = this.doc.getArray('blockInstances');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.doc.destroy();
|
||||
}
|
||||
|
||||
// Create a new element
|
||||
createElement(element: Element): void {
|
||||
this.doc.transact(() => {
|
||||
this.elements.push([element]);
|
||||
});
|
||||
}
|
||||
|
||||
// Update an existing element
|
||||
updateElement(id: string, updates: Partial<Element>): void {
|
||||
this.doc.transact(() => {
|
||||
const index = this.elements.toArray().findIndex(el => el.id === id);
|
||||
if (index !== -1) {
|
||||
const currentElement = this.elements.get(index);
|
||||
const updatedElement = { ...currentElement, ...updates };
|
||||
this.elements.delete(index, 1);
|
||||
this.elements.insert(index, [updatedElement]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete an element
|
||||
deleteElement(id: string): void {
|
||||
this.doc.transact(() => {
|
||||
const index = this.elements.toArray().findIndex(el => el.id === id);
|
||||
if (index !== -1) {
|
||||
this.elements.delete(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get all elements
|
||||
getElements(): Element[] {
|
||||
return this.elements.toArray();
|
||||
}
|
||||
|
||||
// Get element by ID
|
||||
getElementById(id: string): Element | undefined {
|
||||
return this.elements.toArray().find(el => el.id === id);
|
||||
}
|
||||
|
||||
// Helper method to create a rectangle element
|
||||
createRectangle(x: number, y: number, width: number, height: number): void {
|
||||
const element: Element = {
|
||||
id: Date.now().toString(),
|
||||
type: 'rect',
|
||||
layerId: 'default',
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
properties: {}
|
||||
};
|
||||
this.createElement(element);
|
||||
}
|
||||
|
||||
// Helper method to create a circle element
|
||||
createCircle(x: number, y: number, radius: number): void {
|
||||
const element: Element = {
|
||||
id: Date.now().toString(),
|
||||
type: 'circle',
|
||||
layerId: 'default',
|
||||
x: x - radius,
|
||||
y: y - radius,
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
properties: { radius }
|
||||
};
|
||||
this.createElement(element);
|
||||
}
|
||||
|
||||
// Helper method to create a line element
|
||||
createLine(x1: number, y1: number, x2: number, y2: number): void {
|
||||
const element: Element = {
|
||||
id: Date.now().toString(),
|
||||
type: 'line',
|
||||
layerId: 'default',
|
||||
x: Math.min(x1, x2),
|
||||
y: Math.min(y1, y2),
|
||||
width: Math.abs(x2 - x1),
|
||||
height: Math.abs(y2 - y1),
|
||||
properties: { x1, y1, x2, y2 }
|
||||
};
|
||||
this.createElement(element);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { YjsDocument } from './YjsDocument';
|
||||
export { WebSocketProvider } from './WebSocketProvider';
|
||||
export { useYjsBinding } from './useYjsBinding';
|
||||
export { AwarenessManager } from './AwarenessManager';
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
import { YjsDocument } from './YjsDocument';
|
||||
|
||||
export const useYjsBinding = (yjsDoc: YjsDocument) => {
|
||||
const [doc, setDoc] = useState<Y.Doc>(yjsDoc.doc);
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = (update: Uint8Array, origin: any, doc: Y.Doc) => {
|
||||
// Trigger re-render by updating state
|
||||
setDoc({} as Y.Doc); // Force re-render
|
||||
|
||||
// Update rbush index
|
||||
updateRbushIndex(yjsDoc);
|
||||
};
|
||||
|
||||
yjsDoc.doc.on('update', handleUpdate);
|
||||
|
||||
return () => {
|
||||
yjsDoc.doc.off('update', handleUpdate);
|
||||
};
|
||||
}, [yjsDoc]);
|
||||
|
||||
return doc;
|
||||
};
|
||||
|
||||
const updateRbushIndex = (yjsDoc: YjsDocument) => {
|
||||
// Implementation for updating rbush index with Yjs changes
|
||||
// This would integrate with the canvas rendering system
|
||||
console.log('Updating rbush index with Yjs changes');
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
class HistoryManager {
|
||||
private undoStack: Uint8Array[] = [];
|
||||
private redoStack: Uint8Array[] = [];
|
||||
private maxSize: number;
|
||||
private doc: Y.Doc;
|
||||
|
||||
constructor(doc: Y.Doc, maxSize: number = 100) {
|
||||
this.doc = doc;
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
push(update: Uint8Array) {
|
||||
this.undoStack.push(update);
|
||||
if (this.undoStack.length > this.maxSize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
// Clear redo stack when new action is performed
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this.undoStack.length === 0) return false;
|
||||
|
||||
const update = this.undoStack.pop();
|
||||
if (update) {
|
||||
// Apply inverse of the update
|
||||
Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(this.doc, Y.createRelativePositionFromJSON(Y.createRelativePositionFromType(update as any))));
|
||||
this.redoStack.push(update);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (this.redoStack.length === 0) return false;
|
||||
|
||||
const update = this.redoStack.pop();
|
||||
if (update) {
|
||||
Y.applyUpdate(this.doc, update);
|
||||
this.undoStack.push(update);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
}
|
||||
}
|
||||
|
||||
export default HistoryManager;
|
||||
@@ -1 +0,0 @@
|
||||
export { default as HistoryManager } from './HistoryManager';
|
||||
+4
-36
@@ -1,40 +1,8 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import RBush from 'rbush';
|
||||
|
||||
class SelectionEngine {
|
||||
private rbush: RBush<any>;
|
||||
|
||||
constructor() {
|
||||
this.rbush = new RBush();
|
||||
}
|
||||
|
||||
// Single click selection
|
||||
selectSingle(x: number, y: number): any[] {
|
||||
// Implementation for single click selection
|
||||
return [];
|
||||
}
|
||||
|
||||
// Window selection (left-to-right)
|
||||
selectWindow(startX: number, startY: number, endX: number, endY: number): any[] {
|
||||
// Implementation for window selection
|
||||
return [];
|
||||
}
|
||||
|
||||
// Crossing selection (right-to-left)
|
||||
selectCrossing(startX: number, startY: number, endX: number, endY: number): any[] {
|
||||
// Implementation for crossing selection
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fence selection (polyline crossing)
|
||||
selectFence(points: {x: number, y: number}[]): any[] {
|
||||
// Implementation for fence selection
|
||||
return [];
|
||||
}
|
||||
|
||||
// Quick-select (by layer/type/color)
|
||||
quickSelect(criteria: {layer?: string, type?: string, color?: string}): any[] {
|
||||
// Implementation for quick-select
|
||||
return [];
|
||||
}
|
||||
|
||||
// Lasso selection
|
||||
selectLasso(points: {x: number, y: number}[]): any[] {
|
||||
// Implementation for lasso selection
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default SelectionEngine;
|
||||
@@ -1,86 +0,0 @@
|
||||
class SnapEngine {
|
||||
private isEnabled: boolean;
|
||||
private gridSize: number;
|
||||
private tolerance: number;
|
||||
private activeZoom: number;
|
||||
|
||||
constructor(gridSize: number = 10) {
|
||||
this.isEnabled = true;
|
||||
this.gridSize = gridSize;
|
||||
this.tolerance = 5;
|
||||
this.activeZoom = 1;
|
||||
}
|
||||
|
||||
// Toggle snap on/off with F3
|
||||
toggleSnap(): void {
|
||||
this.isEnabled = !this.isEnabled;
|
||||
}
|
||||
|
||||
// Set active zoom level
|
||||
setZoom(zoom: number): void {
|
||||
this.activeZoom = zoom;
|
||||
}
|
||||
|
||||
// Snap to grid
|
||||
snapToGrid(x: number, y: number): {x: number, y: number} | null {
|
||||
if (!this.isEnabled) return null;
|
||||
// Implementation for snap to grid
|
||||
return null;
|
||||
}
|
||||
|
||||
// Snap to endpoint
|
||||
snapToEndpoint(x: number, y: number): {x: number, y: number} | null {
|
||||
if (!this.isEnabled) return null;
|
||||
// Implementation for snap to endpoint
|
||||
return null;
|
||||
}
|
||||
|
||||
// Snap to midpoint
|
||||
snapToMidpoint(x: number, y: number): {x: number, y: number} | null {
|
||||
if (!this.isEnabled) return null;
|
||||
// Implementation for snap to midpoint
|
||||
return null;
|
||||
}
|
||||
|
||||
// Snap to center
|
||||
snapToCenter(x: number, y: number): {x: number, y: number} | null {
|
||||
if (!this.isEnabled) return null;
|
||||
// Implementation for snap to center
|
||||
return null;
|
||||
}
|
||||
|
||||
// Snap to intersection
|
||||
snapToIntersection(x: number, y: number): {x: number, y: number} | null {
|
||||
if (!this.isEnabled) return null;
|
||||
// Implementation for snap to intersection
|
||||
return null;
|
||||
}
|
||||
|
||||
// Snap to perpendicular
|
||||
snapToPerpendicular(x: number, y: number): {x: number, y: number} | null {
|
||||
if (!this.isEnabled) return null;
|
||||
// Implementation for snap to perpendicular
|
||||
return null;
|
||||
}
|
||||
|
||||
// Snap to tangent
|
||||
snapToTangent(x: number, y: number): {x: number, y: number} | null {
|
||||
if (!this.isEnabled) return null;
|
||||
// Implementation for snap to tangent
|
||||
return null;
|
||||
}
|
||||
|
||||
// Snap to nearest
|
||||
snapToNearest(x: number, y: number): {x: number, y: number} | null {
|
||||
if (!this.isEnabled) return null;
|
||||
// Implementation for snap to nearest
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render visual snap markers on canvas
|
||||
renderSnapMarkers(context: CanvasRenderingContext2D): void {
|
||||
// Implementation for rendering snap markers
|
||||
}
|
||||
}
|
||||
|
||||
export default SnapEngine;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as SelectionEngine } from './SelectionEngine';
|
||||
export { default as SnapEngine } from './SnapEngine';
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [drawings, setDrawings] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/drawings').then(res => {
|
||||
setDrawings(res.data);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const res = await api.post('/drawings', { name: 'Neue Zeichnung' });
|
||||
navigate(`/editor/${res.data.id}`);
|
||||
} catch (err) {
|
||||
alert('Fehler beim Erstellen');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Zeichnung wirklich löschen?')) return;
|
||||
try {
|
||||
await api.delete(`/drawings/${id}`);
|
||||
setDrawings(drawings.filter(d => d.id !== id));
|
||||
} catch (err) {
|
||||
alert('Fehler beim Löschen');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: 20 }}>Lade...</div>;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '20px auto', padding: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h2>Meine Zeichnungen ({user?.email})</h2>
|
||||
<button onClick={logout} style={{ padding: '8px 16px', backgroundColor: '#ff4d4f', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Abmelden</button>
|
||||
</div>
|
||||
<button onClick={handleCreate} style={{ padding: '10px 20px', backgroundColor: '#1890ff', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer', marginBottom: 20 }}>Neue Zeichnung</button>
|
||||
{drawings.length === 0 ? (
|
||||
<p>Keine Zeichnungen vorhanden.</p>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr><th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #ddd' }}>Name</th><th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #ddd' }}>Zuletzt geändert</th><th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #ddd' }}>Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{drawings.map(d => (
|
||||
<tr key={d.id}>
|
||||
<td style={{ padding: 8, borderBottom: '1px solid #eee' }}>{d.name}</td>
|
||||
<td style={{ padding: 8, borderBottom: '1px solid #eee' }}>{new Date(d.updatedAt).toLocaleString()}</td>
|
||||
<td style={{ padding: 8, borderBottom: '1px solid #eee' }}>
|
||||
<button onClick={() => navigate(`/editor/${d.id}`)} style={{ marginRight: 8, padding: '4px 12px', backgroundColor: '#52c41a', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Öffnen</button>
|
||||
<button onClick={() => handleDelete(d.id)} style={{ padding: '4px 12px', backgroundColor: '#ff4d4f', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
import CADCanvas from '../components/CADCanvas';
|
||||
import Toolbar from '../components/Toolbar';
|
||||
import LayerPanel from '../components/LayerPanel';
|
||||
import BlockLibrary from '../components/BlockLibrary';
|
||||
import pluginRegistry from '../components/PluginRegistry';
|
||||
|
||||
const Editor = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const canvasRef = useRef(null);
|
||||
const [drawing, setDrawing] = useState(null);
|
||||
const [activeTool, setActiveTool] = useState('select');
|
||||
const [layers, setLayers] = useState([
|
||||
{ name: 'Layer 0', visible: true, locked: false },
|
||||
]);
|
||||
const [activeLayer, setActiveLayer] = useState('Layer 0');
|
||||
const [showLayers, setShowLayers] = useState(true);
|
||||
const [showBlocks, setShowBlocks] = useState(true);
|
||||
const [saveStatus, setSaveStatus] = useState('');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Load drawing
|
||||
useEffect(() => {
|
||||
if (id && id !== 'new') {
|
||||
api.get(`/drawings/${id}`).then(res => {
|
||||
setDrawing(res.data);
|
||||
if (res.data.canvas_json) {
|
||||
try {
|
||||
const json = JSON.parse(res.data.canvas_json);
|
||||
canvasRef.current?.loadFromJSON?.(json);
|
||||
} catch (e) {
|
||||
console.warn('Canvas JSON parse error, starting fresh');
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
navigate('/dashboard');
|
||||
});
|
||||
} else {
|
||||
api.post('/drawings', { name: 'Neue Zeichnung' }).then(res => {
|
||||
setDrawing(res.data);
|
||||
navigate(`/editor/${res.data.id}`, { replace: true });
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
navigate('/dashboard');
|
||||
});
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// Initialize plugin registry when canvas is ready
|
||||
const handleCanvasReady = (canvas) => {
|
||||
const cadApp = {
|
||||
getCanvas: () => canvas,
|
||||
getApi: () => api,
|
||||
getDrawing: () => drawing,
|
||||
registerTool: (id, label, icon, handler) => {
|
||||
console.log(`Plugin registered tool: ${id}`);
|
||||
// Would integrate into toolbar dynamically
|
||||
},
|
||||
};
|
||||
pluginRegistry.init(cadApp);
|
||||
};
|
||||
|
||||
// Save canvas JSON to backend
|
||||
const saveCanvas = async () => {
|
||||
if (!drawing || !canvasRef.current) return;
|
||||
try {
|
||||
setSaveStatus('Speichere...');
|
||||
const json = canvasRef.current.toJSON();
|
||||
await api.put(`/drawings/${drawing.id}`, {
|
||||
canvas_json: JSON.stringify(json),
|
||||
name: drawing.name,
|
||||
});
|
||||
setSaveStatus('Gespeichert ✓');
|
||||
setTimeout(() => setSaveStatus(''), 2000);
|
||||
} catch (err) {
|
||||
setSaveStatus('Fehler ✗');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// DXF Import
|
||||
const handleDxfImport = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await api.post('/dxf/import', formData);
|
||||
if (res.data?.objects && canvasRef.current) {
|
||||
canvasRef.current.addObjects(res.data.objects);
|
||||
setSaveStatus('DXF importiert ✓');
|
||||
setTimeout(() => setSaveStatus(''), 2000);
|
||||
}
|
||||
e.target.value = '';
|
||||
} catch (err) {
|
||||
console.error('DXF import error:', err);
|
||||
setSaveStatus('DXF-Import Fehler ✗');
|
||||
setTimeout(() => setSaveStatus(''), 3000);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// DXF Export
|
||||
const handleDxfExport = async () => {
|
||||
if (!canvasRef.current) return;
|
||||
try {
|
||||
const json = canvasRef.current.toJSON();
|
||||
const res = await api.post('/dxf/export', { objects: json.objects || [] }, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const blob = new Blob([res.data], { type: 'application/dxf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (drawing?.name || 'zeichnung') + '.dxf';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setSaveStatus('DXF exportiert ✓');
|
||||
setTimeout(() => setSaveStatus(''), 2000);
|
||||
} catch (err) {
|
||||
console.error('DXF export error:', err);
|
||||
setSaveStatus('DXF-Export Fehler ✗');
|
||||
setTimeout(() => setSaveStatus(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// SVG Export (client-side)
|
||||
const handleSvgExport = () => {
|
||||
if (!canvasRef.current) return;
|
||||
const canvas = canvasRef.current.getCanvas();
|
||||
if (!canvas) return;
|
||||
try {
|
||||
const svg = canvas.toSVG();
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (drawing?.name || 'zeichnung') + '.svg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setSaveStatus('SVG exportiert ✓');
|
||||
setTimeout(() => setSaveStatus(''), 2000);
|
||||
} catch (err) {
|
||||
console.error('SVG export error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm('Ausgewähltes Objekt löschen?')) return;
|
||||
const canvas = canvasRef.current?.getCanvas();
|
||||
if (canvas) {
|
||||
const active = canvas.getActiveObject();
|
||||
if (active) {
|
||||
canvas.remove(active);
|
||||
canvas.renderAll();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.ctrlKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
saveCanvas();
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
handleDelete();
|
||||
} else if (e.ctrlKey && e.key === 'z') {
|
||||
// Undo would need command stack
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [drawing]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ width: 50, background: '#2c3e50', color: 'white', display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 10, gap: 10, zIndex: 20 }}>
|
||||
<Toolbar activeTool={activeTool} setActiveTool={setActiveTool} canvasRef={canvasRef} />
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
title="Löschen"
|
||||
style={{ width: 36, height: 36, background: '#c0392b', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer', marginTop: 'auto', marginBottom: 10 }}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Canvas area */}
|
||||
<div style={{ flex: 1, position: 'relative', background: '#f0f0f0' }}>
|
||||
<div style={{ position: 'absolute', top: 10, left: 10, zIndex: 10, display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button onClick={() => navigate('/dashboard')} style={{ padding: '4px 12px', background: '#1890ff', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>← Zurück</button>
|
||||
<button onClick={saveCanvas} style={{ padding: '4px 12px', background: '#52c41a', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Speichern</button>
|
||||
<button onClick={() => fileInputRef.current?.click()} style={{ padding: '4px 12px', background: '#faad14', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>DXF Import</button>
|
||||
<button onClick={handleDxfExport} style={{ padding: '4px 12px', background: '#722ed1', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>DXF Export</button>
|
||||
<button onClick={handleSvgExport} style={{ padding: '4px 12px', background: '#13c2c2', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>SVG Export</button>
|
||||
<span style={{ padding: '4px 12px', background: 'white', borderRadius: 4, fontSize: 14 }}>{saveStatus}</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleDxfImport}
|
||||
accept=".dxf"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<CADCanvas
|
||||
ref={canvasRef}
|
||||
activeTool={activeTool}
|
||||
setActiveTool={setActiveTool}
|
||||
onCanvasReady={handleCanvasReady}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right panels */}
|
||||
<div style={{ width: 250, background: '#fafafa', borderLeft: '1px solid #ddd', overflowY: 'auto' }}>
|
||||
<div style={{ padding: 10, borderBottom: '1px solid #ddd', cursor: 'pointer', fontWeight: 'bold', userSelect: 'none' }} onClick={() => setShowLayers(!showLayers)}>
|
||||
Layer {showLayers ? '▲' : '▼'}
|
||||
</div>
|
||||
{showLayers && (
|
||||
<LayerPanel layers={layers} setLayers={setLayers} activeLayer={activeLayer} setActiveLayer={setActiveLayer} />
|
||||
)}
|
||||
<div style={{ padding: 10, borderBottom: '1px solid #ddd', cursor: 'pointer', fontWeight: 'bold', userSelect: 'none' }} onClick={() => setShowBlocks(!showBlocks)}>
|
||||
Teilebibliothek {showBlocks ? '▲' : '▼'}
|
||||
</div>
|
||||
{showBlocks && (
|
||||
<BlockLibrary canvasRef={canvasRef} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Editor;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const Login = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Login fehlgeschlagen');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20, background: 'white', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
|
||||
<h2>Web-CAD Anmeldung</h2>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>E-Mail</label><br />
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label>Passwort</label><br />
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
</div>
|
||||
<button type="submit" style={{ width: '100%', padding: 10, backgroundColor: '#1890ff', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Anmelden</button>
|
||||
</form>
|
||||
<p style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
Kein Konto? <Link to="/register">Registrieren</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const Register = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (password !== passwordConfirm) {
|
||||
setError('Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await register(email, password);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Registrierung fehlgeschlagen');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20, background: 'white', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
|
||||
<h2>Registrierung</h2>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>E-Mail</label><br />
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>Passwort</label><br />
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label>Passwort bestätigen</label><br />
|
||||
<input type="password" value={passwordConfirm} onChange={(e) => setPasswordConfirm(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
</div>
|
||||
<button type="submit" style={{ width: '100%', padding: 10, backgroundColor: '#52c41a', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Registrieren</button>
|
||||
</form>
|
||||
<p style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
Bereits registriert? <Link to="/login">Anmelden</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
@@ -1,576 +0,0 @@
|
||||
import type { YjsDocument } from '../crdt/YjsDocument';
|
||||
import type {
|
||||
CADLayer,
|
||||
CADProperties,
|
||||
CADElement,
|
||||
ExportData,
|
||||
ExportOptions,
|
||||
ExportResult,
|
||||
} from '../types/cad.types';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const clean = hex.replace('#', '');
|
||||
if (clean.length === 3) {
|
||||
return {
|
||||
r: parseInt(clean[0] + clean[0], 16),
|
||||
g: parseInt(clean[1] + clean[1], 16),
|
||||
b: parseInt(clean[2] + clean[2], 16),
|
||||
};
|
||||
}
|
||||
return {
|
||||
r: parseInt(clean.substring(0, 2), 16) || 0,
|
||||
g: parseInt(clean.substring(2, 4), 16) || 0,
|
||||
b: parseInt(clean.substring(4, 6), 16) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToAci(r: number, g: number, b: number): number {
|
||||
if (r > 200 && g > 200 && b > 200) return 7;
|
||||
if (r < 50 && g < 50 && b < 50) return 7;
|
||||
if (r > 200 && g < 100 && b < 100) return 1;
|
||||
if (r < 100 && g > 200 && b < 100) return 3;
|
||||
if (r < 100 && g < 100 && b > 200) return 5;
|
||||
if (r > 200 && g > 200 && b < 100) return 2;
|
||||
if (r > 200 && g < 100 && b > 200) return 6;
|
||||
if (r < 100 && g > 200 && b > 200) return 4;
|
||||
return 7;
|
||||
}
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function getLayerById(layers: CADLayer[], id: string): CADLayer | undefined {
|
||||
return layers.find((l) => l.id === id);
|
||||
}
|
||||
|
||||
function shouldExportElement(
|
||||
element: CADElement,
|
||||
layers: CADLayer[],
|
||||
options: ExportOptions
|
||||
): boolean {
|
||||
if (options.includeInvisibleElements) return true;
|
||||
const layer = getLayerById(layers, element.layerId);
|
||||
if (layer && !layer.visible) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectExportData(yjsDoc: YjsDocument, options: ExportOptions): ExportData {
|
||||
const layers: CADLayer[] = [];
|
||||
yjsDoc.layers.forEach((layer, id) => {
|
||||
if (!options.includeHiddenLayers && !layer.visible) return;
|
||||
layers.push({ ...layer, id });
|
||||
});
|
||||
layers.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
|
||||
const elements = yjsDoc.getElements().filter((el) =>
|
||||
shouldExportElement(el, layers, options)
|
||||
);
|
||||
|
||||
const projectMeta: Record<string, unknown> = {};
|
||||
yjsDoc.projectMeta.forEach((value, key) => {
|
||||
projectMeta[key] = value;
|
||||
});
|
||||
|
||||
return {
|
||||
version: '1.0.0',
|
||||
exportedAt: Date.now(),
|
||||
projectMeta,
|
||||
layers,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
function downloadBlob(content: string | Blob, filename: string, mimeType: string): void {
|
||||
const blob = typeof content === 'string' ? new Blob([content], { type: mimeType }) : content;
|
||||
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);
|
||||
}
|
||||
|
||||
// ── JSON Export ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function exportJSON(yjsDoc: YjsDocument, filename = 'cad-export.json'): ExportResult {
|
||||
try {
|
||||
const data = collectExportData(yjsDoc, {});
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
downloadBlob(json, filename, 'application/json');
|
||||
return { success: true, data: json };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `exportJSON failed: ${(e as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function exportJSONString(yjsDoc: YjsDocument): ExportResult {
|
||||
try {
|
||||
const data = collectExportData(yjsDoc, {});
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
return { success: true, data: json };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `exportJSONString failed: ${(e as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── SVG Export ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', options: ExportOptions = {}): ExportResult {
|
||||
try {
|
||||
const data = collectExportData(yjsDoc, options);
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of data.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);
|
||||
}
|
||||
if (data.elements.length === 0) {
|
||||
minX = 0; minY = 0; maxX = 100; maxY = 100;
|
||||
}
|
||||
|
||||
const padding = 10;
|
||||
const vbX = minX - padding;
|
||||
const vbY = minY - padding;
|
||||
const vbW = (maxX - minX) + padding * 2;
|
||||
const vbH = (maxY - minY) + padding * 2;
|
||||
|
||||
const svgParts: string[] = [];
|
||||
svgParts.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||
svgParts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vbX} ${vbY} ${vbW} ${vbH}" width="${vbW}" height="${vbH}">`);
|
||||
|
||||
svgParts.push('<defs><style>');
|
||||
for (const layer of data.layers) {
|
||||
const color = layer.color || '#000000';
|
||||
const dash = layer.lineType === 'dashed' ? 'stroke-dasharray:8,4;' : layer.lineType === 'dotted' ? 'stroke-dasharray:2,3;' : '';
|
||||
svgParts.push(`.layer-${layer.id} { stroke: ${color}; ${dash} }`);
|
||||
}
|
||||
svgParts.push('</style></defs>');
|
||||
|
||||
for (const el of data.elements) {
|
||||
const layer = getLayerById(data.layers, el.layerId);
|
||||
const stroke = el.properties.stroke || layer?.color || '#000000';
|
||||
const fill = el.properties.fill || 'none';
|
||||
const strokeWidth = el.properties.strokeWidth || 1;
|
||||
const transform = el.properties.rotation ? ` transform="rotate(${el.properties.rotation} ${el.x + el.width / 2} ${el.y + el.height / 2})"` : '';
|
||||
|
||||
const svgY = (vbY + vbH) - el.y;
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = el.properties.x1 ?? el.x;
|
||||
const y1 = (vbY + vbH) - (el.properties.y1 ?? el.y);
|
||||
const x2 = el.properties.x2 ?? (el.x + el.width);
|
||||
const y2 = (vbY + vbH) - (el.properties.y2 ?? (el.y + el.height));
|
||||
svgParts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const radius = el.properties.radius ?? el.width / 2;
|
||||
const cx = el.x + radius;
|
||||
const cy = svgY - radius;
|
||||
svgParts.push(`<circle cx="${cx}" cy="${cy}" r="${radius}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
svgParts.push(`<rect x="${el.x}" y="${svgY - el.height}" width="${el.width}" height="${el.height}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
break;
|
||||
}
|
||||
case 'polyline': {
|
||||
if (el.properties.points && el.properties.points.length > 0) {
|
||||
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
|
||||
svgParts.push(`<polyline points="${pts}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polygon': {
|
||||
if (el.properties.points && el.properties.points.length > 0) {
|
||||
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
|
||||
svgParts.push(`<polygon points="${pts}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const text = escapeXml(el.properties.text || '');
|
||||
const fontSize = el.properties.fontSize || 14;
|
||||
svgParts.push(`<text x="${el.x}" y="${svgY}" font-size="${fontSize}" fill="${stroke}"${transform}>${text}</text>`);
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
const rx = el.width / 2;
|
||||
const ry = el.height / 2;
|
||||
const cx = el.x + rx;
|
||||
const cy = svgY - ry;
|
||||
svgParts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
svgParts.push('</svg>');
|
||||
const svg = svgParts.join('\n');
|
||||
downloadBlob(svg, filename, 'image/svg+xml');
|
||||
return { success: true, data: svg };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `exportSVG failed: ${(e as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── DXF Export ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', options: ExportOptions = {}): ExportResult {
|
||||
try {
|
||||
const data = collectExportData(yjsDoc, options);
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('0'); lines.push('SECTION');
|
||||
lines.push('2'); lines.push('HEADER');
|
||||
lines.push('9'); lines.push('$ACADVER');
|
||||
lines.push('1'); lines.push('AC1009');
|
||||
lines.push('9'); lines.push('$INSBASE');
|
||||
lines.push('10'); lines.push('0.0');
|
||||
lines.push('20'); lines.push('0.0');
|
||||
lines.push('30'); lines.push('0.0');
|
||||
lines.push('0'); lines.push('ENDSEC');
|
||||
|
||||
lines.push('0'); lines.push('SECTION');
|
||||
lines.push('2'); lines.push('TABLES');
|
||||
lines.push('0'); lines.push('TABLE');
|
||||
lines.push('2'); lines.push('LAYER');
|
||||
lines.push('70'); lines.push(String(data.layers.length));
|
||||
|
||||
for (const layer of data.layers) {
|
||||
const color = layer.color || '#000000';
|
||||
const rgb = hexToRgb(color);
|
||||
const aci = rgbToAci(rgb.r, rgb.g, rgb.b);
|
||||
lines.push('0'); lines.push('LAYER');
|
||||
lines.push('2'); lines.push(layer.name || layer.id);
|
||||
lines.push('70'); lines.push('0');
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('6'); lines.push(layer.lineType || 'CONTINUOUS');
|
||||
}
|
||||
|
||||
lines.push('0'); lines.push('ENDTAB');
|
||||
lines.push('0'); lines.push('ENDSEC');
|
||||
|
||||
lines.push('0'); lines.push('SECTION');
|
||||
lines.push('2'); lines.push('ENTITIES');
|
||||
|
||||
for (const el of data.elements) {
|
||||
const layer = getLayerById(data.layers, el.layerId);
|
||||
const layerName = layer?.name || layer?.id || '0';
|
||||
const color = el.properties.stroke || layer?.color || '#000000';
|
||||
const rgb = hexToRgb(color);
|
||||
const aci = rgbToAci(rgb.r, rgb.g, rgb.b);
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = el.properties.x1 ?? el.x;
|
||||
const y1 = el.properties.y1 ?? el.y;
|
||||
const x2 = el.properties.x2 ?? (el.x + el.width);
|
||||
const y2 = el.properties.y2 ?? (el.y + el.height);
|
||||
lines.push('0'); lines.push('LINE');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('10'); lines.push(String(x1));
|
||||
lines.push('20'); lines.push(String(y1));
|
||||
lines.push('30'); lines.push('0.0');
|
||||
lines.push('11'); lines.push(String(x2));
|
||||
lines.push('21'); lines.push(String(y2));
|
||||
lines.push('31'); lines.push('0.0');
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const radius = el.properties.radius ?? el.width / 2;
|
||||
const cx = el.x + radius;
|
||||
const cy = el.y + radius;
|
||||
lines.push('0'); lines.push('CIRCLE');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('10'); lines.push(String(cx));
|
||||
lines.push('20'); lines.push(String(cy));
|
||||
lines.push('30'); lines.push('0.0');
|
||||
lines.push('40'); lines.push(String(radius));
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const x = el.x, y = el.y, w = el.width, h = el.height;
|
||||
lines.push('0'); lines.push('LWPOLYLINE');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('90'); lines.push('4');
|
||||
lines.push('70'); lines.push('1');
|
||||
lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y));
|
||||
lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y));
|
||||
lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y + h));
|
||||
lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y + h));
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const text = el.properties.text || '';
|
||||
const fontSize = el.properties.fontSize || 14;
|
||||
lines.push('0'); lines.push('TEXT');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('10'); lines.push(String(el.x));
|
||||
lines.push('20'); lines.push(String(el.y));
|
||||
lines.push('30'); lines.push('0.0');
|
||||
lines.push('40'); lines.push(String(fontSize));
|
||||
lines.push('1'); lines.push(text);
|
||||
break;
|
||||
}
|
||||
case 'polyline': {
|
||||
if (el.properties.points && el.properties.points.length > 0) {
|
||||
const pts = el.properties.points;
|
||||
lines.push('0'); lines.push('LWPOLYLINE');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('90'); lines.push(String(pts.length));
|
||||
lines.push('70'); lines.push('0');
|
||||
for (const p of pts) {
|
||||
lines.push('10'); lines.push(String(p.x));
|
||||
lines.push('20'); lines.push(String(p.y));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polygon': {
|
||||
if (el.properties.points && el.properties.points.length > 0) {
|
||||
const pts = el.properties.points;
|
||||
lines.push('0'); lines.push('LWPOLYLINE');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('90'); lines.push(String(pts.length));
|
||||
lines.push('70'); lines.push('1');
|
||||
for (const p of pts) {
|
||||
lines.push('10'); lines.push(String(p.x));
|
||||
lines.push('20'); lines.push(String(p.y));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
const radius = el.properties.radius ?? el.width / 2;
|
||||
const cx = el.x + radius;
|
||||
const cy = el.y + radius;
|
||||
// M4 fix: Export actual startAngle (group code 50) and endAngle (group code 51)
|
||||
const startAngle = el.properties.startAngle ?? 0;
|
||||
const endAngle = el.properties.endAngle ?? 360;
|
||||
lines.push('0'); lines.push('ARC');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('10'); lines.push(String(cx));
|
||||
lines.push('20'); lines.push(String(cy));
|
||||
lines.push('30'); lines.push('0.0');
|
||||
lines.push('40'); lines.push(String(radius));
|
||||
lines.push('50'); lines.push(String(startAngle));
|
||||
lines.push('51'); lines.push(String(endAngle));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('0'); lines.push('ENDSEC');
|
||||
lines.push('0'); lines.push('EOF');
|
||||
|
||||
const dxf = lines.join('\n');
|
||||
downloadBlob(dxf, filename, 'application/dxf');
|
||||
return { success: true, data: dxf };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `exportDXF failed: ${(e as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── PDF Export ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function exportPDF(
|
||||
yjsDoc: YjsDocument,
|
||||
filename = 'cad-export.pdf',
|
||||
options: ExportOptions = {}
|
||||
): Promise<ExportResult> {
|
||||
try {
|
||||
const data = collectExportData(yjsDoc, options);
|
||||
|
||||
const { jsPDF } = await import('jspdf');
|
||||
|
||||
const pageSize = options.pageSize || 'a4';
|
||||
const orientation = options.landscape ? 'landscape' : 'portrait';
|
||||
const pdf = new jsPDF({
|
||||
orientation,
|
||||
unit: 'mm',
|
||||
format: pageSize,
|
||||
});
|
||||
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
const margin = 10;
|
||||
const drawableW = pageWidth - margin * 2;
|
||||
const drawableH = pageHeight - margin * 2;
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of data.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);
|
||||
}
|
||||
if (data.elements.length === 0) {
|
||||
minX = 0; minY = 0; maxX = 100; maxY = 100;
|
||||
}
|
||||
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const scale = Math.min(drawableW / contentW, drawableH / contentH, 1);
|
||||
const offsetX = margin + (drawableW - contentW * scale) / 2;
|
||||
const offsetY = margin + (drawableH - contentH * scale) / 2;
|
||||
|
||||
const tx = (x: number) => offsetX + (x - minX) * scale;
|
||||
const ty = (y: number) => pageHeight - offsetY - (y - minY) * scale;
|
||||
|
||||
for (const el of data.elements) {
|
||||
const layer = getLayerById(data.layers, el.layerId);
|
||||
const strokeColor = el.properties.stroke || layer?.color || '#000000';
|
||||
const rgb = hexToRgb(strokeColor);
|
||||
pdf.setDrawColor(rgb.r, rgb.g, rgb.b);
|
||||
pdf.setLineWidth((el.properties.strokeWidth || 1) * scale);
|
||||
|
||||
const fillColor = el.properties.fill;
|
||||
if (fillColor && fillColor !== 'transparent') {
|
||||
const fRgb = hexToRgb(fillColor);
|
||||
pdf.setFillColor(fRgb.r, fRgb.g, fRgb.b);
|
||||
}
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = el.properties.x1 ?? el.x;
|
||||
const y1 = el.properties.y1 ?? el.y;
|
||||
const x2 = el.properties.x2 ?? (el.x + el.width);
|
||||
const y2 = el.properties.y2 ?? (el.y + el.height);
|
||||
pdf.line(tx(x1), ty(y1), tx(x2), ty(y2));
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const radius = el.properties.radius ?? el.width / 2;
|
||||
const cx = tx(el.x + radius);
|
||||
const cy = ty(el.y + radius);
|
||||
pdf.circle(cx, cy, radius * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S');
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const x = tx(el.x);
|
||||
const y = ty(el.y + el.height);
|
||||
pdf.rect(x, y, el.width * scale, el.height * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S');
|
||||
break;
|
||||
}
|
||||
case 'polyline': {
|
||||
if (el.properties.points && el.properties.points.length > 1) {
|
||||
const pts = el.properties.points;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[i + 1].x), ty(pts[i + 1].y));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polygon': {
|
||||
if (el.properties.points && el.properties.points.length > 2) {
|
||||
const pts = el.properties.points;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const next = (i + 1) % pts.length;
|
||||
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[next].x), ty(pts[next].y));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const text = el.properties.text || '';
|
||||
const fontSize = (el.properties.fontSize || 14) * scale;
|
||||
pdf.setFontSize(fontSize);
|
||||
pdf.text(text, tx(el.x), ty(el.y));
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
const radius = el.properties.radius ?? el.width / 2;
|
||||
const cx = tx(el.x + radius);
|
||||
const cy = ty(el.y + radius);
|
||||
pdf.circle(cx, cy, radius * scale, 'S');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(128, 128, 128);
|
||||
pdf.text(
|
||||
`Exported: ${new Date().toISOString()} | Elements: ${data.elements.length} | Layers: ${data.layers.length}`,
|
||||
margin,
|
||||
pageHeight - 5
|
||||
);
|
||||
|
||||
pdf.save(filename);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `exportPDF failed: ${(e as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unified Export ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type ExportFormat = 'json' | 'svg' | 'dxf' | 'pdf';
|
||||
|
||||
export async function exportDrawing(
|
||||
yjsDoc: YjsDocument,
|
||||
format: ExportFormat,
|
||||
filename?: string,
|
||||
options?: ExportOptions
|
||||
): Promise<ExportResult> {
|
||||
const ext = format;
|
||||
const name = filename || `cad-export.${ext}`;
|
||||
|
||||
switch (format) {
|
||||
case 'json':
|
||||
return exportJSON(yjsDoc, name);
|
||||
case 'svg':
|
||||
return exportSVG(yjsDoc, name, options);
|
||||
case 'dxf':
|
||||
return exportDXF(yjsDoc, name, options);
|
||||
case 'pdf':
|
||||
return exportPDF(yjsDoc, name, options);
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: `Unsupported export format: ${format}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
import type { YjsDocument } from '../crdt/YjsDocument';
|
||||
import type {
|
||||
CADLayer,
|
||||
CADProperties,
|
||||
CADElement,
|
||||
ImportData,
|
||||
} from '../types/cad.types';
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean;
|
||||
elementsImported: number;
|
||||
layersImported: number;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ── JSON Import ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Import a JSON string (from exportJSON) into a YjsDocument.
|
||||
* This is the round-trip counterpart to exportJSON.
|
||||
* Clears existing elements and layers before importing.
|
||||
*/
|
||||
export function importJSON(jsonString: string, yjsDoc: YjsDocument): ImportResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
let data: ImportData;
|
||||
try {
|
||||
data = JSON.parse(jsonString) as ImportData;
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
elementsImported: 0,
|
||||
layersImported: 0,
|
||||
errors: [`JSON parse error: ${(e as Error).message}`],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.version || !data.layers || !data.elements) {
|
||||
return {
|
||||
success: false,
|
||||
elementsImported: 0,
|
||||
layersImported: 0,
|
||||
errors: ['Invalid export format: missing version, layers, or elements'],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Clear existing data
|
||||
yjsDoc.doc.transact(() => {
|
||||
// Clear elements
|
||||
const currentElements = yjsDoc.getElements();
|
||||
if (currentElements.length > 0) {
|
||||
yjsDoc.elements.delete(0, currentElements.length);
|
||||
}
|
||||
|
||||
// Clear layers
|
||||
const layerIds: string[] = [];
|
||||
yjsDoc.layers.forEach((_, id) => {
|
||||
layerIds.push(id);
|
||||
});
|
||||
for (const id of layerIds) {
|
||||
yjsDoc.layers.delete(id);
|
||||
}
|
||||
|
||||
// Clear projectMeta
|
||||
const metaKeys: string[] = [];
|
||||
yjsDoc.projectMeta.forEach((_, key) => {
|
||||
metaKeys.push(key);
|
||||
});
|
||||
for (const key of metaKeys) {
|
||||
yjsDoc.projectMeta.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Import layers
|
||||
let layersImported = 0;
|
||||
yjsDoc.doc.transact(() => {
|
||||
for (const layer of data.layers) {
|
||||
if (!layer.id) {
|
||||
warnings.push(`Layer without id skipped: ${layer.name || 'unnamed'}`);
|
||||
continue;
|
||||
}
|
||||
const { id, ...layerData } = layer;
|
||||
yjsDoc.layers.set(id, layerData as never);
|
||||
layersImported++;
|
||||
}
|
||||
});
|
||||
|
||||
// Import elements
|
||||
let elementsImported = 0;
|
||||
yjsDoc.doc.transact(() => {
|
||||
for (const element of data.elements) {
|
||||
if (!element.id || !element.type) {
|
||||
warnings.push(`Element without id or type skipped`);
|
||||
continue;
|
||||
}
|
||||
yjsDoc.createElement(element as never);
|
||||
elementsImported++;
|
||||
}
|
||||
});
|
||||
|
||||
// Import projectMeta
|
||||
if (data.projectMeta) {
|
||||
yjsDoc.doc.transact(() => {
|
||||
for (const [key, value] of Object.entries(data.projectMeta)) {
|
||||
yjsDoc.projectMeta.set(key, value as never);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
elementsImported,
|
||||
layersImported,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a JSON file from a File object (e.g., from <input type="file">).
|
||||
*/
|
||||
export async function importJSONFile(file: File, yjsDoc: YjsDocument): Promise<ImportResult> {
|
||||
const text = await file.text();
|
||||
return importJSON(text, yjsDoc);
|
||||
}
|
||||
|
||||
// ── DXF Import (basic) ────────────────────────────────────────────────────────
|
||||
|
||||
interface DxfEntity {
|
||||
type: string;
|
||||
layer: string;
|
||||
color?: number;
|
||||
data: Record<number, string>;
|
||||
dataArrays: Record<number, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a DXF string into entities.
|
||||
* Supports: LINE, CIRCLE, TEXT, LWPOLYLINE, ARC
|
||||
*/
|
||||
function parseDxf(dxfContent: string): { entities: DxfEntity[]; layers: Map<string, { name: string; color?: string }> } {
|
||||
const lines = dxfContent.split(/\r?\n/);
|
||||
const entities: DxfEntity[] = [];
|
||||
const layers = new Map<string, { name: string; color?: string }>();
|
||||
|
||||
let i = 0;
|
||||
let inEntities = false;
|
||||
let inTables = false;
|
||||
let inLayerTable = false;
|
||||
|
||||
while (i < lines.length) {
|
||||
const code = lines[i]?.trim();
|
||||
const value = lines[i + 1]?.trim() ?? '';
|
||||
|
||||
// Section detection
|
||||
if (code === '0' && value === 'SECTION') {
|
||||
const sectionName = lines[i + 3]?.trim() ?? '';
|
||||
inEntities = sectionName === 'ENTITIES';
|
||||
inTables = sectionName === 'TABLES';
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code === '0' && value === 'ENDSEC') {
|
||||
inEntities = false;
|
||||
inTables = false;
|
||||
inLayerTable = false;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse LAYER table entries
|
||||
if (inTables && code === '0' && value === 'TABLE') {
|
||||
const tableName = lines[i + 3]?.trim() ?? '';
|
||||
inLayerTable = tableName === 'LAYER';
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inTables && code === '0' && value === 'LAYER' && inLayerTable) {
|
||||
// Read layer properties
|
||||
let layerName = '';
|
||||
let layerColor = '';
|
||||
let j = i + 2;
|
||||
while (j < lines.length && lines[j]?.trim() !== '0') {
|
||||
const c = lines[j]?.trim();
|
||||
const v = lines[j + 1]?.trim() ?? '';
|
||||
if (c === '2') layerName = v;
|
||||
if (c === '62') layerColor = v;
|
||||
j += 2;
|
||||
}
|
||||
if (layerName) {
|
||||
layers.set(layerName, { name: layerName, color: layerColor });
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse entities
|
||||
if (inEntities && code === '0' && value !== 'ENDSEC') {
|
||||
const entityType = value;
|
||||
const entityData: Record<number, string> = {};
|
||||
const entityDataArrays: Record<number, string[]> = {};
|
||||
let entityLayer = '0';
|
||||
let entityColor: number | undefined;
|
||||
|
||||
let j = i + 2;
|
||||
while (j < lines.length && lines[j]?.trim() !== '0') {
|
||||
const c = lines[j]?.trim();
|
||||
const v = lines[j + 1]?.trim() ?? '';
|
||||
const codeNum = parseInt(c, 10);
|
||||
if (c === '8') entityLayer = v;
|
||||
if (c === '62') entityColor = parseInt(v, 10);
|
||||
// Store last value in entityData (for non-repeated codes)
|
||||
entityData[codeNum] = v;
|
||||
// Collect all values per group code into arrays (for repeated codes like 10/20)
|
||||
if (!entityDataArrays[codeNum]) {
|
||||
entityDataArrays[codeNum] = [];
|
||||
}
|
||||
entityDataArrays[codeNum].push(v);
|
||||
j += 2;
|
||||
}
|
||||
|
||||
entities.push({
|
||||
type: entityType,
|
||||
layer: entityLayer,
|
||||
color: entityColor,
|
||||
data: entityData,
|
||||
dataArrays: entityDataArrays,
|
||||
});
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
i += 2;
|
||||
}
|
||||
|
||||
return { entities, layers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a DXF string into a YjsDocument.
|
||||
* Supports basic LINE, CIRCLE, TEXT, LWPOLYLINE, ARC entities.
|
||||
* Clears existing elements and layers before importing.
|
||||
*/
|
||||
export function importDXF(dxfContent: string, yjsDoc: YjsDocument): ImportResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const { entities, layers: dxfLayers } = parseDxf(dxfContent);
|
||||
|
||||
// Clear existing data (M2 fix: clear canvas and Yjs document before import)
|
||||
yjsDoc.doc.transact(() => {
|
||||
// Clear elements
|
||||
const currentElements = yjsDoc.getElements();
|
||||
if (currentElements.length > 0) {
|
||||
yjsDoc.elements.delete(0, currentElements.length);
|
||||
}
|
||||
|
||||
// Clear layers
|
||||
const layerIds: string[] = [];
|
||||
yjsDoc.layers.forEach((_, id) => {
|
||||
layerIds.push(id);
|
||||
});
|
||||
for (const id of layerIds) {
|
||||
yjsDoc.layers.delete(id);
|
||||
}
|
||||
|
||||
// Clear projectMeta
|
||||
const metaKeys: string[] = [];
|
||||
yjsDoc.projectMeta.forEach((_, key) => {
|
||||
metaKeys.push(key);
|
||||
});
|
||||
for (const key of metaKeys) {
|
||||
yjsDoc.projectMeta.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Import layers
|
||||
let layersImported = 0;
|
||||
yjsDoc.doc.transact(() => {
|
||||
for (const [name, info] of dxfLayers) {
|
||||
const layerId = name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
if (!yjsDoc.layers.has(layerId)) {
|
||||
yjsDoc.layers.set(layerId, {
|
||||
id: layerId,
|
||||
name,
|
||||
visible: true,
|
||||
locked: false,
|
||||
color: info.color ? aciToHex(parseInt(info.color, 10)) : undefined,
|
||||
} as never);
|
||||
layersImported++;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure default layer exists
|
||||
if (!yjsDoc.layers.has('default')) {
|
||||
yjsDoc.layers.set('default', {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
visible: true,
|
||||
locked: false,
|
||||
} as never);
|
||||
layersImported++;
|
||||
}
|
||||
});
|
||||
|
||||
// Import entities
|
||||
let elementsImported = 0;
|
||||
yjsDoc.doc.transact(() => {
|
||||
for (const entity of entities) {
|
||||
const layerId = entity.layer.replace(/[^a-zA-Z0-9_-]/g, '_') || 'default';
|
||||
const stroke = entity.color ? aciToHex(entity.color) : '#000000';
|
||||
const id = `dxf-${Date.now()}-${elementsImported}`;
|
||||
|
||||
switch (entity.type) {
|
||||
case 'LINE': {
|
||||
const x1 = parseFloat(entity.data[10] || '0');
|
||||
const y1 = parseFloat(entity.data[20] || '0');
|
||||
const x2 = parseFloat(entity.data[11] || '0');
|
||||
const y2 = parseFloat(entity.data[21] || '0');
|
||||
yjsDoc.createElement({
|
||||
id,
|
||||
type: 'line',
|
||||
layerId,
|
||||
x: Math.min(x1, x2),
|
||||
y: Math.min(y1, y2),
|
||||
width: Math.abs(x2 - x1),
|
||||
height: Math.abs(y2 - y1),
|
||||
properties: { x1, y1, x2, y2, stroke, strokeWidth: 1 },
|
||||
} as never);
|
||||
elementsImported++;
|
||||
break;
|
||||
}
|
||||
case 'CIRCLE': {
|
||||
const cx = parseFloat(entity.data[10] || '0');
|
||||
const cy = parseFloat(entity.data[20] || '0');
|
||||
const radius = parseFloat(entity.data[40] || '0');
|
||||
yjsDoc.createElement({
|
||||
id,
|
||||
type: 'circle',
|
||||
layerId,
|
||||
x: cx - radius,
|
||||
y: cy - radius,
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
properties: { radius, stroke, strokeWidth: 1, fill: 'transparent' },
|
||||
} as never);
|
||||
elementsImported++;
|
||||
break;
|
||||
}
|
||||
case 'TEXT': {
|
||||
const x = parseFloat(entity.data[10] || '0');
|
||||
const y = parseFloat(entity.data[20] || '0');
|
||||
const fontSize = parseFloat(entity.data[40] || '14');
|
||||
const text = entity.data[1] || '';
|
||||
yjsDoc.createElement({
|
||||
id,
|
||||
type: 'text',
|
||||
layerId,
|
||||
x,
|
||||
y,
|
||||
width: text.length * fontSize * 0.6,
|
||||
height: fontSize,
|
||||
properties: { text, fontSize, stroke },
|
||||
} as never);
|
||||
elementsImported++;
|
||||
break;
|
||||
}
|
||||
case 'LWPOLYLINE': {
|
||||
// M1 fix: Use dataArrays to collect repeated group codes 10/20
|
||||
// instead of overwriting single values in entityData
|
||||
const numVerts = parseInt(entity.data[90] || '0', 10);
|
||||
const closed = parseInt(entity.data[70] || '0', 10) === 1;
|
||||
const xs = entity.dataArrays[10] || [];
|
||||
const ys = entity.dataArrays[20] || [];
|
||||
const vertexCount = numVerts > 0 ? numVerts : Math.min(xs.length, ys.length);
|
||||
const points: Array<{ x: number; y: number }> = [];
|
||||
for (let v = 0; v < vertexCount; v++) {
|
||||
const px = parseFloat(xs[v] || '0');
|
||||
const py = parseFloat(ys[v] || '0');
|
||||
points.push({ x: px, y: py });
|
||||
}
|
||||
if (points.length > 0) {
|
||||
const allXs = points.map(p => p.x);
|
||||
const allYs = points.map(p => p.y);
|
||||
yjsDoc.createElement({
|
||||
id,
|
||||
type: closed ? 'polygon' : 'polyline',
|
||||
layerId,
|
||||
x: Math.min(...allXs),
|
||||
y: Math.min(...allYs),
|
||||
width: Math.max(...allXs) - Math.min(...allXs),
|
||||
height: Math.max(...allYs) - Math.min(...allYs),
|
||||
properties: { points, stroke, strokeWidth: 1, fill: closed ? 'transparent' : undefined },
|
||||
} as never);
|
||||
elementsImported++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ARC': {
|
||||
const cx = parseFloat(entity.data[10] || '0');
|
||||
const cy = parseFloat(entity.data[20] || '0');
|
||||
const radius = parseFloat(entity.data[40] || '0');
|
||||
// M4 fix: Read startAngle (group code 50) and endAngle (group code 51)
|
||||
const startAngle = entity.data[50] !== undefined ? parseFloat(entity.data[50]) : 0;
|
||||
const endAngle = entity.data[51] !== undefined ? parseFloat(entity.data[51]) : 360;
|
||||
yjsDoc.createElement({
|
||||
id,
|
||||
type: 'arc',
|
||||
layerId,
|
||||
x: cx - radius,
|
||||
y: cy - radius,
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
properties: { radius, stroke, strokeWidth: 1, fill: 'transparent', startAngle, endAngle },
|
||||
} as never);
|
||||
elementsImported++;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
warnings.push(`Unsupported DXF entity type: ${entity.type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
elementsImported,
|
||||
layersImported,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a DXF file from a File object.
|
||||
*/
|
||||
export async function importDXFFile(file: File, yjsDoc: YjsDocument): Promise<ImportResult> {
|
||||
const text = await file.text();
|
||||
return importDXF(text, yjsDoc);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function aciToHex(aci: number): string {
|
||||
const aciMap: Record<number, string> = {
|
||||
1: '#ff0000', // red
|
||||
2: '#ffff00', // yellow
|
||||
3: '#00ff00', // green
|
||||
4: '#00ffff', // cyan
|
||||
5: '#0000ff', // blue
|
||||
6: '#ff00ff', // magenta
|
||||
7: '#ffffff', // white
|
||||
};
|
||||
return aciMap[aci] || '#000000';
|
||||
}
|
||||
|
||||
// ── Unified Import ────────────────────────────────────────────────────────────
|
||||
|
||||
export type ImportFormat = 'json' | 'dxf';
|
||||
|
||||
export async function importDrawing(
|
||||
file: File,
|
||||
format: ImportFormat,
|
||||
yjsDoc: YjsDocument
|
||||
): Promise<ImportResult> {
|
||||
switch (format) {
|
||||
case 'json':
|
||||
return importJSONFile(file, yjsDoc);
|
||||
case 'dxf':
|
||||
return importDXFFile(file, yjsDoc);
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
elementsImported: 0,
|
||||
layersImported: 0,
|
||||
errors: [`Unsupported import format: ${format}`],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
import type { YjsDocument } from '../crdt/YjsDocument';
|
||||
|
||||
export type PageSize = 'a4' | 'a3' | 'a2' | 'a1';
|
||||
export type Orientation = 'portrait' | 'landscape';
|
||||
export type ScalePreset = '1:50' | '1:100' | '1:200' | 'custom';
|
||||
|
||||
export interface PrintSettings {
|
||||
pageSize: PageSize;
|
||||
orientation: Orientation;
|
||||
scale: ScalePreset;
|
||||
customScale: number;
|
||||
showBorder: boolean;
|
||||
showTitleBlock: boolean;
|
||||
titleBlockData?: TitleBlockData;
|
||||
margin: number;
|
||||
}
|
||||
|
||||
export interface TitleBlockData {
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
scale: string;
|
||||
sheet: string;
|
||||
}
|
||||
|
||||
export interface PageLayout {
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
drawableWidth: number;
|
||||
drawableHeight: number;
|
||||
cols: number;
|
||||
rows: number;
|
||||
totalPages: number;
|
||||
scale: number;
|
||||
scaleValue: number;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
minX: number;
|
||||
minY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PrintPage {
|
||||
index: number;
|
||||
col: number;
|
||||
row: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
svg: string;
|
||||
}
|
||||
|
||||
export interface PrintResult {
|
||||
settings: PrintSettings;
|
||||
layout: PageLayout;
|
||||
bbox: BoundingBox;
|
||||
pages: PrintPage[];
|
||||
}
|
||||
|
||||
const PAGE_DIMENSIONS: Record<PageSize, { width: number; height: number }> = {
|
||||
a4: { width: 210, height: 297 },
|
||||
a3: { width: 297, height: 420 },
|
||||
a2: { width: 420, height: 594 },
|
||||
a1: { width: 594, height: 841 },
|
||||
};
|
||||
|
||||
const SCALE_VALUES: Record<ScalePreset, number> = {
|
||||
'1:50': 50,
|
||||
'1:100': 100,
|
||||
'1:200': 200,
|
||||
custom: 100,
|
||||
};
|
||||
|
||||
export const DEFAULT_PRINT_SETTINGS: PrintSettings = {
|
||||
pageSize: 'a4',
|
||||
orientation: 'landscape',
|
||||
scale: '1:100',
|
||||
customScale: 100,
|
||||
showBorder: true,
|
||||
showTitleBlock: true,
|
||||
titleBlockData: {
|
||||
title: 'CAD Drawing',
|
||||
author: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
scale: '1:100',
|
||||
sheet: '1/1',
|
||||
},
|
||||
margin: 10,
|
||||
};
|
||||
|
||||
export function calculateBoundingBox(yjsDoc: YjsDocument): BoundingBox {
|
||||
const elements = yjsDoc.getElements();
|
||||
if (elements.length === 0) {
|
||||
return { minX: 0, minY: 0, width: 100, height: 100 };
|
||||
}
|
||||
|
||||
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,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculatePageLayout(
|
||||
bbox: BoundingBox,
|
||||
settings: PrintSettings
|
||||
): PageLayout {
|
||||
const dims = PAGE_DIMENSIONS[settings.pageSize];
|
||||
const pageWidth = settings.orientation === 'landscape' ? dims.height : dims.width;
|
||||
const pageHeight = settings.orientation === 'landscape' ? dims.width : dims.height;
|
||||
|
||||
const margin = settings.margin;
|
||||
const drawableWidth = pageWidth - margin * 2;
|
||||
const drawableHeight = pageHeight - margin * 2;
|
||||
|
||||
const scaleValue = settings.scale === 'custom' ? settings.customScale : SCALE_VALUES[settings.scale];
|
||||
const scale = 1 / scaleValue;
|
||||
|
||||
const cadUnitsPerPageW = drawableWidth * scaleValue;
|
||||
const cadUnitsPerPageH = drawableHeight * scaleValue;
|
||||
|
||||
const cols = Math.max(1, Math.ceil(bbox.width / cadUnitsPerPageW));
|
||||
const rows = Math.max(1, Math.ceil(bbox.height / cadUnitsPerPageH));
|
||||
const totalPages = cols * rows;
|
||||
|
||||
return {
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
drawableWidth,
|
||||
drawableHeight,
|
||||
cols,
|
||||
rows,
|
||||
totalPages,
|
||||
scale,
|
||||
scaleValue,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function generatePageSVG(
|
||||
yjsDoc: YjsDocument,
|
||||
bbox: BoundingBox,
|
||||
layout: PageLayout,
|
||||
settings: PrintSettings,
|
||||
pageIndex: number,
|
||||
col: number,
|
||||
row: number
|
||||
): string {
|
||||
const elements = yjsDoc.getElements();
|
||||
const scaleValue = layout.scaleValue;
|
||||
|
||||
const cadUnitsPerPageW = layout.drawableWidth * scaleValue;
|
||||
const cadUnitsPerPageH = layout.drawableHeight * scaleValue;
|
||||
|
||||
const offsetX = bbox.minX + col * cadUnitsPerPageW;
|
||||
const offsetY = bbox.minY + row * cadUnitsPerPageH;
|
||||
|
||||
const vbW = layout.drawableWidth;
|
||||
const vbH = layout.drawableHeight;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||
parts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${vbW} ${vbH}" width="${vbW}mm" height="${vbH}mm">`);
|
||||
|
||||
parts.push(`<defs><clipPath id="pageClip"><rect x="0" y="0" width="${vbW}" height="${vbH}" /></clipPath></defs>`);
|
||||
parts.push(`<g clip-path="url(#pageClip)">`);
|
||||
|
||||
for (const el of elements) {
|
||||
const stroke = (el.properties as Record<string, unknown>).stroke as string || '#000000';
|
||||
const fill = (el.properties as Record<string, unknown>).fill as string || 'none';
|
||||
const strokeWidth = ((el.properties as Record<string, unknown>).strokeWidth as number || 1) / scaleValue;
|
||||
const fillResolved = fill === 'transparent' ? 'none' : fill;
|
||||
|
||||
const sx = (cadX: number) => (cadX - offsetX) / scaleValue;
|
||||
const sy = (cadY: number) => vbH - (cadY - offsetY) / scaleValue;
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = sx((el.properties as Record<string, unknown>).x1 as number ?? el.x);
|
||||
const y1 = sy((el.properties as Record<string, unknown>).y1 as number ?? el.y);
|
||||
const x2 = sx((el.properties as Record<string, unknown>).x2 as number ?? el.x + el.width);
|
||||
const y2 = sy((el.properties as Record<string, unknown>).y2 as number ?? el.y + el.height);
|
||||
parts.push(`<line x1="${x1.toFixed(2)}" y1="${y1.toFixed(2)}" x2="${x2.toFixed(2)}" y2="${y2.toFixed(2)}" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const radius = (el.properties as Record<string, unknown>).radius as number ?? el.width / 2;
|
||||
const cx = sx(el.x + radius);
|
||||
const cy = sy(el.y + radius);
|
||||
const r = radius / scaleValue;
|
||||
parts.push(`<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${r.toFixed(2)}" fill="${fillResolved}" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const x = sx(el.x);
|
||||
const y = sy(el.y + el.height);
|
||||
const w = el.width / scaleValue;
|
||||
const h = el.height / scaleValue;
|
||||
parts.push(`<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${w.toFixed(2)}" height="${h.toFixed(2)}" fill="${fillResolved}" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
|
||||
break;
|
||||
}
|
||||
case 'polyline': {
|
||||
const pts = (el.properties as Record<string, unknown>).points as Array<{ x: number; y: number }> | undefined;
|
||||
if (pts && pts.length > 0) {
|
||||
const ptsStr = pts.map(p => `${sx(p.x).toFixed(2)},${sy(p.y).toFixed(2)}`).join(' ');
|
||||
parts.push(`<polyline points="${ptsStr}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polygon': {
|
||||
const pts = (el.properties as Record<string, unknown>).points as Array<{ x: number; y: number }> | undefined;
|
||||
if (pts && pts.length > 0) {
|
||||
const ptsStr = pts.map(p => `${sx(p.x).toFixed(2)},${sy(p.y).toFixed(2)}`).join(' ');
|
||||
parts.push(`<polygon points="${ptsStr}" fill="${fillResolved}" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const text = escapeXml((el.properties as Record<string, unknown>).text as string || '');
|
||||
const fontSize = ((el.properties as Record<string, unknown>).fontSize as number || 14) / scaleValue;
|
||||
parts.push(`<text x="${sx(el.x).toFixed(2)}" y="${sy(el.y).toFixed(2)}" font-size="${fontSize.toFixed(1)}" fill="${stroke}">${text}</text>`);
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
const radius = (el.properties as Record<string, unknown>).radius as number ?? el.width / 2;
|
||||
const cx = sx(el.x + radius);
|
||||
const cy = sy(el.y + radius);
|
||||
const r = radius / scaleValue;
|
||||
parts.push(`<ellipse cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" rx="${r.toFixed(2)}" ry="${r.toFixed(2)}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
parts.push('</g>');
|
||||
|
||||
if (settings.showBorder) {
|
||||
parts.push(`<rect x="0" y="0" width="${vbW}" height="${vbH}" fill="none" stroke="#000" stroke-width="0.5" />`);
|
||||
}
|
||||
|
||||
if (settings.showTitleBlock && settings.titleBlockData) {
|
||||
const tb = settings.titleBlockData;
|
||||
const tbW = 80;
|
||||
const tbH = 30;
|
||||
const tbX = vbW - tbW - 2;
|
||||
const tbY = vbH - tbH - 2;
|
||||
parts.push(`<rect x="${tbX}" y="${tbY}" width="${tbW}" height="${tbH}" fill="none" stroke="#000" stroke-width="0.3" />`);
|
||||
parts.push(`<line x1="${tbX}" y1="${tbY + 10}" x2="${tbX + tbW}" y2="${tbY + 10}" stroke="#000" stroke-width="0.2" />`);
|
||||
parts.push(`<line x1="${tbX + tbW / 2}" y1="${tbY}" x2="${tbX + tbW / 2}" y2="${tbY + tbH}" stroke="#000" stroke-width="0.2" />`);
|
||||
parts.push(`<text x="${tbX + 3}" y="${tbY + 7}" font-size="3" fill="#000">${escapeXml(tb.title)}</text>`);
|
||||
parts.push(`<text x="${tbX + 3}" y="${tbY + 17}" font-size="2.5" fill="#000">Scale: ${escapeXml(tb.scale)}</text>`);
|
||||
parts.push(`<text x="${tbX + 3}" y="${tbY + 25}" font-size="2.5" fill="#000">${escapeXml(tb.author)}</text>`);
|
||||
parts.push(`<text x="${tbX + tbW / 2 + 3}" y="${tbY + 17}" font-size="2.5" fill="#000">${escapeXml(tb.date)}</text>`);
|
||||
parts.push(`<text x="${tbX + tbW / 2 + 3}" y="${tbY + 25}" font-size="2.5" fill="#000">Sheet: ${pageIndex + 1}/${layout.totalPages}</text>`);
|
||||
}
|
||||
|
||||
parts.push('</svg>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export function preparePrint(
|
||||
yjsDoc: YjsDocument,
|
||||
settings: PrintSettings
|
||||
): PrintResult {
|
||||
const bbox = calculateBoundingBox(yjsDoc);
|
||||
const layout = calculatePageLayout(bbox, settings);
|
||||
|
||||
const pages: PrintPage[] = [];
|
||||
for (let row = 0; row < layout.rows; row++) {
|
||||
for (let col = 0; col < layout.cols; col++) {
|
||||
const index = row * layout.cols + col;
|
||||
const cadUnitsPerPageW = layout.drawableWidth * layout.scaleValue;
|
||||
const cadUnitsPerPageH = layout.drawableHeight * layout.scaleValue;
|
||||
const offsetX = bbox.minX + col * cadUnitsPerPageW;
|
||||
const offsetY = bbox.minY + row * cadUnitsPerPageH;
|
||||
const visibleW = Math.min(cadUnitsPerPageW, bbox.minX + bbox.width - offsetX);
|
||||
const visibleH = Math.min(cadUnitsPerPageH, bbox.minY + bbox.height - offsetY);
|
||||
|
||||
pages.push({
|
||||
index,
|
||||
col,
|
||||
row,
|
||||
offsetX,
|
||||
offsetY,
|
||||
width: visibleW,
|
||||
height: visibleH,
|
||||
svg: generatePageSVG(yjsDoc, bbox, layout, settings, index, col, row),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
layout,
|
||||
bbox,
|
||||
pages,
|
||||
};
|
||||
}
|
||||
|
||||
export function printPages(printResult: PrintResult): void {
|
||||
const printWindow = window.open('', '_blank', 'width=800,height=600');
|
||||
if (!printWindow) {
|
||||
alert('Please allow popups to print.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { layout, pages, settings } = printResult;
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>CAD Print — ${settings.titleBlockData?.title || 'Drawing'}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: ${settings.pageSize} ${settings.orientation};
|
||||
margin: ${settings.margin}mm;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.print-page {
|
||||
width: ${layout.pageWidth}mm;
|
||||
height: ${layout.pageHeight}mm;
|
||||
page-break-after: always;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: ${settings.margin}mm;
|
||||
}
|
||||
.print-page:last-child {
|
||||
page-break-after: auto;
|
||||
}
|
||||
.print-page svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@media print {
|
||||
.print-page {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${pages.map(p => `<div class="print-page">${p.svg}</div>`).join('\n')}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
|
||||
printWindow.onload = () => {
|
||||
setTimeout(() => {
|
||||
printWindow.print();
|
||||
}, 500);
|
||||
};
|
||||
}
|
||||
|
||||
export function svgToDataUrl(svg: string): string {
|
||||
const encoded = encodeURIComponent(svg);
|
||||
return `data:image/svg+xml;charset=utf-8,${encoded}`;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { YjsDocument } from '../crdt/YjsDocument';
|
||||
export abstract class Tool {
|
||||
protected yjsDoc: YjsDocument;
|
||||
protected name: string;
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
this.yjsDoc = yjsDoc;
|
||||
this.name = '';
|
||||
}
|
||||
|
||||
abstract onMouseDown(event: MouseEvent): void;
|
||||
abstract onMouseMove(event: MouseEvent): void;
|
||||
abstract onMouseUp(event: MouseEvent): void;
|
||||
abstract onKeyUp(event: KeyboardEvent): void;
|
||||
abstract reset(): void;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { YjsDocument } from '../crdt/YjsDocument';
|
||||
import { Tool } from './Tool';
|
||||
|
||||
interface ToolConstructor {
|
||||
new (yjsDoc: YjsDocument): Tool;
|
||||
}
|
||||
|
||||
class ToolManager {
|
||||
private yjsDoc: YjsDocument;
|
||||
private activeTool: Tool | null = null;
|
||||
private tools: Map<string, ToolConstructor> = new Map();
|
||||
private canvas: HTMLElement;
|
||||
|
||||
constructor(yjsDoc: YjsDocument, canvas: HTMLElement) {
|
||||
this.yjsDoc = yjsDoc;
|
||||
this.canvas = canvas;
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
registerTool(name: string, toolConstructor: ToolConstructor): void {
|
||||
this.tools.set(name, toolConstructor);
|
||||
}
|
||||
|
||||
activateTool(toolName: string): void {
|
||||
// Reset current tool if exists
|
||||
if (this.activeTool) {
|
||||
this.activeTool.reset();
|
||||
}
|
||||
|
||||
// Create and activate new tool
|
||||
const ToolClass = this.tools.get(toolName);
|
||||
if (ToolClass) {
|
||||
this.activeTool = new ToolClass(this.yjsDoc);
|
||||
}
|
||||
}
|
||||
|
||||
deactivateTool(): void {
|
||||
if (this.activeTool) {
|
||||
this.activeTool.reset();
|
||||
this.activeTool = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
||||
this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
||||
document.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||
}
|
||||
|
||||
private handleMouseDown(event: MouseEvent): void {
|
||||
if (this.activeTool) {
|
||||
this.activeTool.onMouseDown(event);
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseMove(event: MouseEvent): void {
|
||||
if (this.activeTool) {
|
||||
this.activeTool.onMouseMove(event);
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseUp(event: MouseEvent): void {
|
||||
if (this.activeTool) {
|
||||
this.activeTool.onMouseUp(event);
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyUp(event: KeyboardEvent): void {
|
||||
if (this.activeTool) {
|
||||
this.activeTool.onKeyUp(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { ToolManager };
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class ArcTool extends Tool {
|
||||
private centerPoint: { x: number; y: number } | null = null;
|
||||
private startPoint: { x: number; y: number } | null = null;
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'ArcTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
if (!this.centerPoint) {
|
||||
this.centerPoint = { x: event.clientX, y: event.clientY };
|
||||
} else if (!this.startPoint) {
|
||||
this.startPoint = { x: event.clientX, y: event.clientY };
|
||||
} else {
|
||||
// Calculate start and end angles and create arc element in Yjs document
|
||||
const startAngle = Math.atan2(
|
||||
this.startPoint.y - this.centerPoint.y,
|
||||
this.startPoint.x - this.centerPoint.x
|
||||
);
|
||||
|
||||
const endAngle = Math.atan2(
|
||||
event.clientY - this.centerPoint.y,
|
||||
event.clientX - this.centerPoint.x
|
||||
);
|
||||
|
||||
const radius = Math.sqrt(
|
||||
Math.pow(this.startPoint.x - this.centerPoint.x, 2) +
|
||||
Math.pow(this.startPoint.y - this.centerPoint.y, 2)
|
||||
);
|
||||
|
||||
this.yjsDoc.createArc(
|
||||
this.centerPoint.x,
|
||||
this.centerPoint.y,
|
||||
radius,
|
||||
startAngle,
|
||||
endAngle
|
||||
);
|
||||
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// Preview arc while drawing
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// Handle mouse up if needed
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
if (event.key === 'Escape') {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.centerPoint = null;
|
||||
this.startPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { ArcTool };
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class AutoChairPlacementTool extends Tool {
|
||||
private chairSpacing: number = 60; // Default spacing between chairs
|
||||
private chairsPerSide: number = 2; // Default chairs per side for rectangular tables
|
||||
private chairsAroundRound: number = 8; // Default chairs around round tables
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'AutoChairPlacementTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
// This tool works on selected tables, not on mouse clicks
|
||||
// The actual placement logic would be triggered through a UI action
|
||||
// rather than a mouse click on the canvas
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// No preview needed for this tool
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// No action needed on mouse up
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
if (event.key === 'Escape') {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
// Reset any state if needed
|
||||
}
|
||||
|
||||
// Place chairs around a rectangular table
|
||||
placeChairsAroundRectangularTable(table: any): void {
|
||||
const { x, y, width, height } = table;
|
||||
const chairWidth = 45;
|
||||
const chairHeight = 45;
|
||||
|
||||
// Calculate chairs per side based on table dimensions
|
||||
const chairsOnLongSides = this.chairsPerSide;
|
||||
const chairsOnShortSides = Math.max(1, Math.floor(this.chairsPerSide * (Math.min(width, height) / Math.max(width, height))));
|
||||
|
||||
// Place chairs on the longer sides (top and bottom)
|
||||
for (let i = 0; i < chairsOnLongSides; i++) {
|
||||
const positionRatio = (i + 1) / (chairsOnLongSides + 1);
|
||||
|
||||
// Top side
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_chair_top_' + i,
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x: x + positionRatio * width - chairWidth / 2,
|
||||
y: y - this.chairSpacing - chairHeight,
|
||||
width: chairWidth,
|
||||
height: chairHeight,
|
||||
properties: {}
|
||||
});
|
||||
|
||||
// Bottom side
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_chair_bottom_' + i,
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x: x + positionRatio * width - chairWidth / 2,
|
||||
y: y + height + this.chairSpacing,
|
||||
width: chairWidth,
|
||||
height: chairHeight,
|
||||
properties: {}
|
||||
});
|
||||
}
|
||||
|
||||
// Place chairs on the shorter sides (left and right)
|
||||
for (let i = 0; i < chairsOnShortSides; i++) {
|
||||
const positionRatio = (i + 1) / (chairsOnShortSides + 1);
|
||||
|
||||
// Left side
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_chair_left_' + i,
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x: x - this.chairSpacing - chairWidth,
|
||||
y: y + positionRatio * height - chairHeight / 2,
|
||||
width: chairWidth,
|
||||
height: chairHeight,
|
||||
properties: {}
|
||||
});
|
||||
|
||||
// Right side
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_chair_right_' + i,
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x: x + width + this.chairSpacing,
|
||||
y: y + positionRatio * height - chairHeight / 2,
|
||||
width: chairWidth,
|
||||
height: chairHeight,
|
||||
properties: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Place chairs around a round table
|
||||
placeChairsAroundRoundTable(table: any): void {
|
||||
const { centerX, centerY, radius } = table.properties;
|
||||
const chairWidth = 45;
|
||||
const chairHeight = 45;
|
||||
|
||||
// Place chairs in a circle around the table
|
||||
for (let i = 0; i < this.chairsAroundRound; i++) {
|
||||
const angle = (2 * Math.PI * i) / this.chairsAroundRound;
|
||||
const chairX = centerX + (radius + this.chairSpacing) * Math.cos(angle) - chairWidth / 2;
|
||||
const chairY = centerY + (radius + this.chairSpacing) * Math.sin(angle) - chairHeight / 2;
|
||||
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_chair_' + i,
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x: chairX,
|
||||
y: chairY,
|
||||
width: chairWidth,
|
||||
height: chairHeight,
|
||||
properties: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Place chairs on selected tables
|
||||
placeChairsOnSelectedTables(selectedTableIds: string[]): void {
|
||||
// Get all elements from the document
|
||||
const elements = this.yjsDoc.getElements();
|
||||
|
||||
// Filter for selected tables
|
||||
const selectedTables = elements.filter(element =>
|
||||
selectedTableIds.includes(element.id) &&
|
||||
(element.type === 'rectangular_table' || element.type === 'round_table')
|
||||
);
|
||||
|
||||
// Place chairs around each selected table
|
||||
selectedTables.forEach(table => {
|
||||
if (table.type === 'rectangular_table') {
|
||||
this.placeChairsAroundRectangularTable(table);
|
||||
} else if (table.type === 'round_table') {
|
||||
this.placeChairsAroundRoundTable(table);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set chair spacing
|
||||
setChairSpacing(spacing: number): void {
|
||||
this.chairSpacing = spacing;
|
||||
}
|
||||
|
||||
// Set chairs per side for rectangular tables
|
||||
setChairsPerSide(count: number): void {
|
||||
this.chairsPerSide = count;
|
||||
}
|
||||
|
||||
// Set chairs around round tables
|
||||
setChairsAroundRound(count: number): void {
|
||||
this.chairsAroundRound = count;
|
||||
}
|
||||
}
|
||||
|
||||
export { AutoChairPlacementTool };
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class CapacityDisplayTool extends Tool {
|
||||
private onCapacityUpdate: (totalCapacity: number, selectedCapacity: number) => void;
|
||||
|
||||
constructor(yjsDoc: YjsDocument, onCapacityUpdate?: (totalCapacity: number, selectedCapacity: number) => void) {
|
||||
super(yjsDoc);
|
||||
this.name = 'CapacityDisplayTool';
|
||||
this.onCapacityUpdate = onCapacityUpdate || (() => {});
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
// This tool doesn't create elements on mouse click
|
||||
// It displays capacity information based on selected elements or entire drawing
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// No preview needed for this tool
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// No action needed on mouse up
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
if (event.key === 'Escape') {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
// Reset any state if needed
|
||||
}
|
||||
|
||||
// Calculate capacity for selected tables
|
||||
calculateSelectedCapacity(): number {
|
||||
// In a real implementation, this would get selected tables from the canvas
|
||||
// For now, we'll count all chair elements as selected capacity
|
||||
const elements = this.yjsDoc.getElements();
|
||||
let count = 0;
|
||||
elements.forEach(element => {
|
||||
if (element.type === 'chair') {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
// Calculate capacity for entire drawing
|
||||
calculateTotalCapacity(): number {
|
||||
// Iterate through all elements in the Yjs document
|
||||
// and count chairs
|
||||
const elements = this.yjsDoc.getElements();
|
||||
let count = 0;
|
||||
elements.forEach(element => {
|
||||
if (element.type === 'chair') {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
// Display capacity information
|
||||
displayCapacity(): void {
|
||||
const totalCapacity = this.calculateTotalCapacity();
|
||||
const selectedCapacity = this.calculateSelectedCapacity();
|
||||
|
||||
// Update the UI through the callback
|
||||
this.onCapacityUpdate(totalCapacity, selectedCapacity);
|
||||
}
|
||||
}
|
||||
|
||||
export { CapacityDisplayTool };
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class ChairTool extends Tool {
|
||||
private width: number = 45; // Default chair width
|
||||
private height: number = 45; // Default chair height
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'ChairTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
// Create chair element at click position
|
||||
const x = event.clientX - this.width / 2;
|
||||
const y = event.clientY - this.height / 2;
|
||||
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x,
|
||||
y,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
properties: {
|
||||
// Chair-specific properties can be added here
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// Preview chair at cursor position
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// Handle mouse up if needed
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
if (event.key === 'Escape') {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
// Reset any state if needed
|
||||
}
|
||||
|
||||
// Set chair dimensions
|
||||
setDimensions(width: number, height: number): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
export { ChairTool };
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class CircleTool extends Tool {
|
||||
private centerPoint: { x: number; y: number } | null = null;
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'CircleTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
if (!this.centerPoint) {
|
||||
this.centerPoint = { x: event.clientX, y: event.clientY };
|
||||
} else {
|
||||
// Calculate radius and create circle element in Yjs document
|
||||
const radius = Math.sqrt(
|
||||
Math.pow(event.clientX - this.centerPoint.x, 2) +
|
||||
Math.pow(event.clientY - this.centerPoint.y, 2)
|
||||
);
|
||||
this.yjsDoc.createCircle(this.centerPoint.x, this.centerPoint.y, radius);
|
||||
this.centerPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// Preview circle while dragging
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// Handle mouse up if needed
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.centerPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { CircleTool };
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class DimensionTool extends Tool {
|
||||
private startPoint: { x: number; y: number } | null = null;
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'DimensionTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
if (!this.startPoint) {
|
||||
this.startPoint = { x: event.clientX, y: event.clientY };
|
||||
} else {
|
||||
// Create dimension element in Yjs document
|
||||
this.yjsDoc.createDimension(
|
||||
this.startPoint.x,
|
||||
this.startPoint.y,
|
||||
event.clientX,
|
||||
event.clientY
|
||||
);
|
||||
this.startPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// Preview dimension while dragging
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// Handle mouse up if needed
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.startPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { DimensionTool };
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class LineTool extends Tool {
|
||||
private startPoint: { x: number; y: number } | null = null;
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'LineTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
if (!this.startPoint) {
|
||||
this.startPoint = { x: event.clientX, y: event.clientY };
|
||||
} else {
|
||||
// Create line element in Yjs document
|
||||
this.yjsDoc.createLine(this.startPoint.x, this.startPoint.y, event.clientX, event.clientY);
|
||||
this.startPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// Preview line while dragging
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// Handle mouse up if needed
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.startPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { LineTool };
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class PolygonTool extends Tool {
|
||||
private points: Array<{ x: number; y: number }> = [];
|
||||
private isDrawing: boolean = false;
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'PolygonTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
if (!this.isDrawing) {
|
||||
// Start drawing
|
||||
this.isDrawing = true;
|
||||
this.points = [{ x: event.clientX, y: event.clientY }];
|
||||
} else {
|
||||
// Add point to polygon
|
||||
this.points.push({ x: event.clientX, y: event.clientY });
|
||||
|
||||
// If shift key is not pressed, finish the polygon (close it)
|
||||
if (!event.shiftKey) {
|
||||
this.yjsDoc.createPolygon([...this.points]);
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// Preview polygon while drawing
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// Handle mouse up if needed
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
if (event.key === 'Escape') {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.points = [];
|
||||
this.isDrawing = false;
|
||||
}
|
||||
}
|
||||
|
||||
export { PolygonTool };
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class PolylineTool extends Tool {
|
||||
private points: Array<{ x: number; y: number }> = [];
|
||||
private isDrawing: boolean = false;
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'PolylineTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
if (!this.isDrawing) {
|
||||
// Start drawing
|
||||
this.isDrawing = true;
|
||||
this.points = [{ x: event.clientX, y: event.clientY }];
|
||||
} else {
|
||||
// Add point to polyline
|
||||
this.points.push({ x: event.clientX, y: event.clientY });
|
||||
|
||||
// If shift key is not pressed, finish the polyline
|
||||
if (!event.shiftKey) {
|
||||
this.yjsDoc.createPolyline([...this.points]);
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// Preview polyline while drawing
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// Handle mouse up if needed
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
if (event.key === 'Escape') {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.points = [];
|
||||
this.isDrawing = false;
|
||||
}
|
||||
}
|
||||
|
||||
export { PolylineTool };
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class RectTool extends Tool {
|
||||
private startPoint: { x: number; y: number } | null = null;
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'RectTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
if (!this.startPoint) {
|
||||
this.startPoint = { x: event.clientX, y: event.clientY };
|
||||
} else {
|
||||
// Create rectangle element in Yjs document
|
||||
const x = Math.min(this.startPoint.x, event.clientX);
|
||||
const y = Math.min(this.startPoint.y, event.clientY);
|
||||
const width = Math.abs(event.clientX - this.startPoint.x);
|
||||
const height = Math.abs(event.clientY - this.startPoint.y);
|
||||
this.yjsDoc.createRectangle(x, y, width, height);
|
||||
this.startPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// Preview rectangle while dragging
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// Handle mouse up if needed
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.startPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { RectTool };
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class RotateSeatingGroupTool extends Tool {
|
||||
private selectedElementIds: string[] = [];
|
||||
private rotationAngle: number = 90; // Default rotation angle in degrees
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'RotateSeatingGroupTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
// This tool works on selected elements, not on mouse clicks
|
||||
// The actual rotation logic would be triggered through a UI action
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// No preview needed for this tool
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// No action needed on mouse up
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
if (event.key === 'Escape') {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
// Reset any state if needed
|
||||
this.selectedElementIds = [];
|
||||
}
|
||||
|
||||
// Rotate selected elements
|
||||
rotateSelectedElements(): void {
|
||||
// Get all elements from the document
|
||||
const elements = this.yjsDoc.getElements();
|
||||
|
||||
// Filter for selected elements
|
||||
const selectedElements = elements.filter(element =>
|
||||
this.selectedElementIds.includes(element.id)
|
||||
);
|
||||
|
||||
// Rotate each selected element
|
||||
selectedElements.forEach(element => {
|
||||
// Calculate new rotation (add rotation angle, wrapping at 360 degrees)
|
||||
let newRotation = element.properties.rotation || 0;
|
||||
newRotation = (newRotation + this.rotationAngle) % 360;
|
||||
|
||||
// Update element with new rotation property
|
||||
this.yjsDoc.updateElement(element.id, {
|
||||
properties: {
|
||||
...element.properties,
|
||||
rotation: newRotation
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Set selected elements
|
||||
setSelectedElements(elementIds: string[]): void {
|
||||
this.selectedElementIds = elementIds;
|
||||
}
|
||||
|
||||
// Set rotation angle
|
||||
setRotationAngle(angle: number): void {
|
||||
this.rotationAngle = angle;
|
||||
}
|
||||
}
|
||||
|
||||
export { RotateSeatingGroupTool };
|
||||
@@ -1,340 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
type SeatingTemplate = 'bankett' | 'gala' | 'stehempfang' | 'parlament' | 'klasse';
|
||||
|
||||
class SeatingTemplateTool extends Tool {
|
||||
private template: SeatingTemplate = 'bankett';
|
||||
private tableSpacing: number = 150; // Spacing between tables
|
||||
private chairSpacing: number = 60; // Spacing between chairs
|
||||
|
||||
constructor(yjsDoc: YjsDocument, template: SeatingTemplate = 'bankett') {
|
||||
super(yjsDoc);
|
||||
this.name = 'SeatingTemplateTool';
|
||||
this.template = template;
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
// This tool creates templates at the click position
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
|
||||
switch (this.template) {
|
||||
case 'bankett':
|
||||
this.createBankettTemplate(x, y);
|
||||
break;
|
||||
case 'gala':
|
||||
this.createGalaTemplate(x, y);
|
||||
break;
|
||||
case 'stehempfang':
|
||||
this.createStehempfangTemplate(x, y);
|
||||
break;
|
||||
case 'parlament':
|
||||
this.createParlamentTemplate(x, y);
|
||||
break;
|
||||
case 'klasse':
|
||||
this.createKlasseTemplate(x, y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// Preview template at cursor position
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// Handle mouse up if needed
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
if (event.key === 'Escape') {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
// Reset any state if needed
|
||||
}
|
||||
|
||||
// Create Bankett template (long tables with chairs on both sides)
|
||||
private createBankettTemplate(x: number, y: number): void {
|
||||
const tableWidth = 240;
|
||||
const tableHeight = 100;
|
||||
const chairWidth = 45;
|
||||
const chairHeight = 45;
|
||||
const tablesCount = 3;
|
||||
|
||||
for (let i = 0; i < tablesCount; i++) {
|
||||
const tableX = x + i * (tableWidth + this.tableSpacing);
|
||||
const tableY = y;
|
||||
|
||||
// Create table
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_table_' + i,
|
||||
type: 'rectangular_table',
|
||||
layerId: 'default',
|
||||
x: tableX,
|
||||
y: tableY,
|
||||
width: tableWidth,
|
||||
height: tableHeight,
|
||||
properties: {}
|
||||
});
|
||||
|
||||
// Create chairs on both sides
|
||||
const chairsPerSide = 4;
|
||||
for (let j = 0; j < chairsPerSide; j++) {
|
||||
const positionRatio = (j + 1) / (chairsPerSide + 1);
|
||||
|
||||
// Top side chairs
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_chair_top_' + i + '_' + j,
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x: tableX + positionRatio * tableWidth - chairWidth / 2,
|
||||
y: tableY - this.chairSpacing - chairHeight,
|
||||
width: chairWidth,
|
||||
height: chairHeight,
|
||||
properties: {}
|
||||
});
|
||||
|
||||
// Bottom side chairs
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_chair_bottom_' + i + '_' + j,
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x: tableX + positionRatio * tableWidth - chairWidth / 2,
|
||||
y: tableY + tableHeight + this.chairSpacing,
|
||||
width: chairWidth,
|
||||
height: chairHeight,
|
||||
properties: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create Gala template (round tables with chairs around)
|
||||
private createGalaTemplate(x: number, y: number): void {
|
||||
const tableRadius = 50;
|
||||
const tablesPerRow = 3;
|
||||
const tablesRows = 2;
|
||||
|
||||
for (let i = 0; i < tablesRows; i++) {
|
||||
for (let j = 0; j < tablesPerRow; j++) {
|
||||
const tableX = x + j * (tableRadius * 2 + this.tableSpacing);
|
||||
const tableY = y + i * (tableRadius * 2 + this.tableSpacing);
|
||||
|
||||
// Create round table
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_table_' + i + '_' + j,
|
||||
type: 'round_table',
|
||||
layerId: 'default',
|
||||
x: tableX - tableRadius,
|
||||
y: tableY - tableRadius,
|
||||
width: tableRadius * 2,
|
||||
height: tableRadius * 2,
|
||||
properties: {
|
||||
radius: tableRadius,
|
||||
centerX: tableX,
|
||||
centerY: tableY
|
||||
}
|
||||
});
|
||||
|
||||
// Create chairs around table
|
||||
const chairsAround = 8;
|
||||
for (let k = 0; k < chairsAround; k++) {
|
||||
const angle = (2 * Math.PI * k) / chairsAround;
|
||||
const chairX = tableX + (tableRadius + this.chairSpacing) * Math.cos(angle) - 22.5;
|
||||
const chairY = tableY + (tableRadius + this.chairSpacing) * Math.sin(angle) - 22.5;
|
||||
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_chair_' + i + '_' + j + '_' + k,
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x: chairX,
|
||||
y: chairY,
|
||||
width: 45,
|
||||
height: 45,
|
||||
properties: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create Stehempfang template (no chairs, just tables for drinks/food)
|
||||
private createStehempfangTemplate(x: number, y: number): void {
|
||||
const tableWidth = 120;
|
||||
const tableHeight = 60;
|
||||
const tablesPerRow = 4;
|
||||
const tablesRows = 2;
|
||||
|
||||
for (let i = 0; i < tablesRows; i++) {
|
||||
for (let j = 0; j < tablesPerRow; j++) {
|
||||
const tableX = x + j * (tableWidth + this.tableSpacing);
|
||||
const tableY = y + i * (tableHeight + this.tableSpacing);
|
||||
|
||||
// Create table
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_table_' + i + '_' + j,
|
||||
type: 'rectangular_table',
|
||||
layerId: 'default',
|
||||
x: tableX,
|
||||
y: tableY,
|
||||
width: tableWidth,
|
||||
height: tableHeight,
|
||||
properties: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create Parlament template (U-shape with chairs)
|
||||
private createParlamentTemplate(x: number, y: number): void {
|
||||
const tableWidth = 200;
|
||||
const tableHeight = 60;
|
||||
const chairWidth = 45;
|
||||
const chairHeight = 45;
|
||||
|
||||
// Create main table (president's table)
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_main_table',
|
||||
type: 'rectangular_table',
|
||||
layerId: 'default',
|
||||
x: x + tableWidth / 2,
|
||||
y: y,
|
||||
width: tableWidth,
|
||||
height: tableHeight,
|
||||
properties: {}
|
||||
});
|
||||
|
||||
// Create side tables (delegates' tables)
|
||||
const sideTablesCount = 4;
|
||||
for (let i = 0; i < sideTablesCount; i++) {
|
||||
// Left side table
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_left_table_' + i,
|
||||
type: 'rectangular_table',
|
||||
layerId: 'default',
|
||||
x: x,
|
||||
y: y + (i + 1) * (tableHeight + this.tableSpacing),
|
||||
width: tableWidth / 2,
|
||||
height: tableHeight,
|
||||
properties: {}
|
||||
});
|
||||
|
||||
// Right side table
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_right_table_' + i,
|
||||
type: 'rectangular_table',
|
||||
layerId: 'default',
|
||||
x: x + tableWidth + tableWidth / 2,
|
||||
y: y + (i + 1) * (tableHeight + this.tableSpacing),
|
||||
width: tableWidth / 2,
|
||||
height: tableHeight,
|
||||
properties: {}
|
||||
});
|
||||
|
||||
// Create chairs for side tables
|
||||
// Left side chairs
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_left_chair_' + i,
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x: x - this.chairSpacing - chairWidth,
|
||||
y: y + (i + 1) * (tableHeight + this.tableSpacing) + tableHeight / 2 - chairHeight / 2,
|
||||
width: chairWidth,
|
||||
height: chairHeight,
|
||||
properties: {}
|
||||
});
|
||||
|
||||
// Right side chairs
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_right_chair_' + i,
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x: x + tableWidth + tableWidth / 2 + tableWidth / 2 + this.chairSpacing,
|
||||
y: y + (i + 1) * (tableHeight + this.tableSpacing) + tableHeight / 2 - chairHeight / 2,
|
||||
width: chairWidth,
|
||||
height: chairHeight,
|
||||
properties: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create Klasse template (U-shape classroom layout open at bottom)
|
||||
private createKlasseTemplate(x: number, y: number): void {
|
||||
const tableWidth = 120;
|
||||
const tableHeight = 60;
|
||||
const chairWidth = 45;
|
||||
const chairHeight = 45;
|
||||
|
||||
// Create teacher's table
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_teacher_table',
|
||||
type: 'rectangular_table',
|
||||
layerId: 'default',
|
||||
x: x + tableWidth,
|
||||
y: y,
|
||||
width: tableWidth,
|
||||
height: tableHeight,
|
||||
properties: {}
|
||||
});
|
||||
|
||||
// Create student tables in U-shape (open at bottom)
|
||||
const rows = 4;
|
||||
const cols = 5;
|
||||
|
||||
for (let i = 0; i < rows; i++) {
|
||||
for (let j = 0; j < cols; j++) {
|
||||
// Create U-shape that is open at the bottom
|
||||
// Skip inner tables but keep the bottom row
|
||||
if (i > 0 && i < rows - 1 && j > 0 && j < cols - 1) continue;
|
||||
|
||||
const tableX = x + j * (tableWidth + this.tableSpacing);
|
||||
const tableY = y + (i + 1) * (tableHeight + this.tableSpacing);
|
||||
|
||||
// Create table
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_student_table_' + i + '_' + j,
|
||||
type: 'rectangular_table',
|
||||
layerId: 'default',
|
||||
x: tableX,
|
||||
y: tableY,
|
||||
width: tableWidth,
|
||||
height: tableHeight,
|
||||
properties: {}
|
||||
});
|
||||
|
||||
// Create chair behind each table
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID() + '_student_chair_' + i + '_' + j,
|
||||
type: 'chair',
|
||||
layerId: 'default',
|
||||
x: tableX + tableWidth / 2 - chairWidth / 2,
|
||||
y: tableY + tableHeight + this.chairSpacing,
|
||||
width: chairWidth,
|
||||
height: chairHeight,
|
||||
properties: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set template
|
||||
setTemplate(template: SeatingTemplate): void {
|
||||
this.template = template;
|
||||
}
|
||||
|
||||
// Set table spacing
|
||||
setTableSpacing(spacing: number): void {
|
||||
this.tableSpacing = spacing;
|
||||
}
|
||||
|
||||
// Set chair spacing
|
||||
setChairSpacing(spacing: number): void {
|
||||
this.chairSpacing = spacing;
|
||||
}
|
||||
}
|
||||
|
||||
export { SeatingTemplateTool };
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
type TableType = 'rectangular' | 'round';
|
||||
|
||||
class TableTool extends Tool {
|
||||
private startPoint: { x: number; y: number } | null = null;
|
||||
private tableType: TableType = 'rectangular';
|
||||
private radius: number = 37.5; // Default radius for round table (75cm diameter)
|
||||
private width: number = 120; // Default width for rectangular table
|
||||
private height: number = 80; // Default height for rectangular table
|
||||
|
||||
constructor(yjsDoc: YjsDocument, tableType: TableType = 'rectangular') {
|
||||
super(yjsDoc);
|
||||
this.name = 'TableTool';
|
||||
this.tableType = tableType;
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
if (!this.startPoint) {
|
||||
this.startPoint = { x: event.clientX, y: event.clientY };
|
||||
} else {
|
||||
// Create table element in Yjs document
|
||||
if (this.tableType === 'round') {
|
||||
// Create round table (circle)
|
||||
const x = this.startPoint.x;
|
||||
const y = this.startPoint.y;
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'round_table',
|
||||
layerId: 'default',
|
||||
x: x - this.radius,
|
||||
y: y - this.radius,
|
||||
width: this.radius * 2,
|
||||
height: this.radius * 2,
|
||||
properties: {
|
||||
radius: this.radius,
|
||||
centerX: x,
|
||||
centerY: y
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Create rectangular table (rectangle)
|
||||
const x = Math.min(this.startPoint.x, event.clientX);
|
||||
const y = Math.min(this.startPoint.y, event.clientY);
|
||||
const width = Math.abs(event.clientX - this.startPoint.x);
|
||||
const height = Math.abs(event.clientY - this.startPoint.y);
|
||||
this.yjsDoc.createElement({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'rectangular_table',
|
||||
layerId: 'default',
|
||||
x,
|
||||
y,
|
||||
width: width || this.width,
|
||||
height: height || this.height,
|
||||
properties: {}
|
||||
});
|
||||
}
|
||||
this.startPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// Preview table while dragging
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// Handle mouse up if needed
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
if (event.key === 'Escape') {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.startPoint = null;
|
||||
}
|
||||
|
||||
// Set table parameters
|
||||
setRadius(radius: number): void {
|
||||
this.radius = radius;
|
||||
}
|
||||
|
||||
setDimensions(width: number, height: number): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
export { TableTool };
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class TextTool extends Tool {
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'TextTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
// Get text input from user
|
||||
const text = prompt('Enter text:');
|
||||
if (text !== null && text !== '') {
|
||||
// Create text element in Yjs document
|
||||
this.yjsDoc.createText(event.clientX, event.clientY, text);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// No preview needed for text tool
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// Handle mouse up if needed
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
}
|
||||
}
|
||||
|
||||
export { TextTool };
|
||||
@@ -1,49 +0,0 @@
|
||||
import { LineTool } from './LineTool';
|
||||
import { CircleTool } from './CircleTool';
|
||||
import { PolylineTool } from './PolylineTool';
|
||||
import { PolygonTool } from './PolygonTool';
|
||||
import { RectTool } from './RectTool';
|
||||
import { ArcTool } from './ArcTool';
|
||||
import { TextTool } from './TextTool';
|
||||
import { DimensionTool } from './DimensionTool';
|
||||
import { TableTool } from './TableTool';
|
||||
import { ChairTool } from './ChairTool';
|
||||
import { AutoChairPlacementTool } from './AutoChairPlacementTool';
|
||||
import { SeatingTemplateTool } from './SeatingTemplateTool';
|
||||
import { CapacityDisplayTool } from './CapacityDisplayTool';
|
||||
|
||||
// Export all drawing tools
|
||||
export {
|
||||
LineTool,
|
||||
CircleTool,
|
||||
PolylineTool,
|
||||
PolygonTool,
|
||||
RectTool,
|
||||
ArcTool,
|
||||
TextTool,
|
||||
DimensionTool,
|
||||
TableTool,
|
||||
ChairTool,
|
||||
AutoChairPlacementTool,
|
||||
SeatingTemplateTool,
|
||||
CapacityDisplayTool
|
||||
};
|
||||
|
||||
// Tool registry
|
||||
const drawingTools = [
|
||||
LineTool,
|
||||
CircleTool,
|
||||
PolylineTool,
|
||||
PolygonTool,
|
||||
RectTool,
|
||||
ArcTool,
|
||||
TextTool,
|
||||
DimensionTool,
|
||||
TableTool,
|
||||
ChairTool,
|
||||
AutoChairPlacementTool,
|
||||
SeatingTemplateTool,
|
||||
CapacityDisplayTool
|
||||
];
|
||||
|
||||
export default drawingTools;
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { Canvas } from '../../canvas/Canvas';
|
||||
|
||||
export class CopyTool extends Tool {
|
||||
name = 'Copy';
|
||||
icon = 'copy';
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
super(canvas);
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// Implementation for copy tool
|
||||
// Select elements, get base point and target point
|
||||
// Create copies of elements in Yjs document
|
||||
// Add to undo history
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { Canvas } from '../../canvas/Canvas';
|
||||
|
||||
export class ExtendTool extends Tool {
|
||||
name = 'Extend';
|
||||
icon = 'extend';
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
super(canvas);
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// Implementation for extend tool
|
||||
// Select boundary and elements to extend
|
||||
// Update Yjs document with extended elements
|
||||
// Add to undo history
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { Canvas } from '../../canvas/Canvas';
|
||||
|
||||
export class FilletTool extends Tool {
|
||||
name = 'Fillet';
|
||||
icon = 'fillet';
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
super(canvas);
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// Implementation for fillet tool
|
||||
// Select two elements and radius
|
||||
// Update Yjs document with filleted elements
|
||||
// Add to undo history
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { Canvas } from '../../canvas/Canvas';
|
||||
|
||||
export class MirrorTool extends Tool {
|
||||
name = 'Mirror';
|
||||
icon = 'mirror';
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
super(canvas);
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// Implementation for mirror tool
|
||||
// Select elements, get mirror axis
|
||||
// Update Yjs document with mirrored positions
|
||||
// Add to undo history
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { Canvas } from '../../canvas/Canvas';
|
||||
|
||||
export class MoveTool extends Tool {
|
||||
name = 'Move';
|
||||
icon = 'move';
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
super(canvas);
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// Implementation for move tool
|
||||
// Select elements, get base point and target point
|
||||
// Update Yjs document with new positions
|
||||
// Add to undo history
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { Canvas } from '../../canvas/Canvas';
|
||||
|
||||
export class OffsetTool extends Tool {
|
||||
name = 'Offset';
|
||||
icon = 'offset';
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
super(canvas);
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// Implementation for offset tool
|
||||
// Select elements and distance
|
||||
// Update Yjs document with offset elements
|
||||
// Add to undo history
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { YjsDocument } from '../../crdt/YjsDocument';
|
||||
|
||||
class RotateSeatingGroupTool extends Tool {
|
||||
private selectedElements: any[] = [];
|
||||
private rotationCenter: { x: number; y: number } | null = null;
|
||||
private currentAngle: number = 0;
|
||||
|
||||
constructor(yjsDoc: YjsDocument) {
|
||||
super(yjsDoc);
|
||||
this.name = 'RotateSeatingGroupTool';
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
// This tool works on selected elements, not on mouse clicks
|
||||
// The actual rotation logic would be triggered through a UI action
|
||||
// rather than a mouse click on the canvas
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
// No preview needed for this tool
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
// No action needed on mouse up
|
||||
}
|
||||
|
||||
onKeyUp(event: KeyboardEvent): void {
|
||||
// Handle key up events
|
||||
if (event.key === 'Escape') {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.selectedElements = [];
|
||||
this.rotationCenter = null;
|
||||
this.currentAngle = 0;
|
||||
}
|
||||
|
||||
// Set selected elements for rotation
|
||||
setSelectedElements(elements: any[]): void {
|
||||
this.selectedElements = elements;
|
||||
|
||||
// Calculate rotation center as the centroid of all selected elements
|
||||
if (elements.length > 0) {
|
||||
let totalX = 0;
|
||||
let totalY = 0;
|
||||
|
||||
elements.forEach(element => {
|
||||
totalX += element.x + element.width / 2;
|
||||
totalY += element.y + element.height / 2;
|
||||
});
|
||||
|
||||
this.rotationCenter = {
|
||||
x: totalX / elements.length,
|
||||
y: totalY / elements.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate selected elements by a given angle
|
||||
rotateElements(angle: number): void {
|
||||
if (!this.rotationCenter || this.selectedElements.length === 0) return;
|
||||
|
||||
// Update current angle
|
||||
this.currentAngle += angle;
|
||||
|
||||
// Rotate each element around the rotation center
|
||||
this.selectedElements.forEach(element => {
|
||||
// Calculate current center of element
|
||||
const elementCenterX = element.x + element.width / 2;
|
||||
const elementCenterY = element.y + element.height / 2;
|
||||
|
||||
// Calculate vector from rotation center to element center
|
||||
const dx = elementCenterX - this.rotationCenter!.x;
|
||||
const dy = elementCenterY - this.rotationCenter!.y;
|
||||
|
||||
// Calculate new position after rotation
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const newX = this.rotationCenter!.x + dx * cos - dy * sin;
|
||||
const newY = this.rotationCenter!.y + dx * sin + dy * cos;
|
||||
|
||||
// Update element position (maintaining original size)
|
||||
const newElementX = newX - element.width / 2;
|
||||
const newElementY = newY - element.height / 2;
|
||||
|
||||
// In a real implementation, this would update the element in the Yjs document
|
||||
// For now, we'll just log the new position
|
||||
console.log(`Rotating element ${element.id} to (${newElementX}, ${newElementY})`);
|
||||
|
||||
// Update element in Yjs document
|
||||
this.yjsDoc.updateElement(element.id, {
|
||||
x: newElementX,
|
||||
y: newElementY
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Rotate by 90 degrees
|
||||
rotate90(): void {
|
||||
this.rotateElements(Math.PI / 2);
|
||||
}
|
||||
|
||||
// Rotate by 180 degrees
|
||||
rotate180(): void {
|
||||
this.rotateElements(Math.PI);
|
||||
}
|
||||
|
||||
// Rotate by 270 degrees
|
||||
rotate270(): void {
|
||||
this.rotateElements(Math.PI * 3 / 2);
|
||||
}
|
||||
}
|
||||
|
||||
export { RotateSeatingGroupTool };
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { Canvas } from '../../canvas/Canvas';
|
||||
|
||||
export class RotateTool extends Tool {
|
||||
name = 'Rotate';
|
||||
icon = 'rotate';
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
super(canvas);
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// Implementation for rotate tool
|
||||
// Select elements, get center point and angle
|
||||
// Update Yjs document with rotated positions
|
||||
// Add to undo history
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { Canvas } from '../../canvas/Canvas';
|
||||
|
||||
export class ScaleTool extends Tool {
|
||||
name = 'Scale';
|
||||
icon = 'scale';
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
super(canvas);
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// Implementation for scale tool
|
||||
// Select elements, get base point and scale factor
|
||||
// Update Yjs document with scaled positions
|
||||
// Add to undo history
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Tool } from '../Tool';
|
||||
import { Canvas } from '../../canvas/Canvas';
|
||||
|
||||
export class TrimTool extends Tool {
|
||||
name = 'Trim';
|
||||
icon = 'trim';
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
super(canvas);
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// Implementation for trim tool
|
||||
// Select boundary and elements to trim
|
||||
// Update Yjs document with trimmed elements
|
||||
// Add to undo history
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export { MoveTool } from './MoveTool';
|
||||
export { CopyTool } from './CopyTool';
|
||||
export { RotateTool } from './RotateTool';
|
||||
export { ScaleTool } from './ScaleTool';
|
||||
export { MirrorTool } from './MirrorTool';
|
||||
export { TrimTool } from './TrimTool';
|
||||
export { ExtendTool } from './ExtendTool';
|
||||
export { FilletTool } from './FilletTool';
|
||||
export { OffsetTool } from './OffsetTool';
|
||||
export { RotateSeatingGroupTool } from './RotateSeatingGroupTool';
|
||||
|
||||
export const modificationTools = [
|
||||
MoveTool,
|
||||
CopyTool,
|
||||
RotateTool,
|
||||
ScaleTool,
|
||||
MirrorTool,
|
||||
TrimTool,
|
||||
ExtendTool,
|
||||
FilletTool,
|
||||
OffsetTool,
|
||||
RotateSeatingGroupTool
|
||||
];
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Shared CAD type definitions — single source of truth.
|
||||
* Import from here instead of duplicating interfaces across services/components.
|
||||
*/
|
||||
|
||||
export interface CADLayer {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
color?: string;
|
||||
lineType?: string;
|
||||
transparency?: number;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface CADProperties {
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
rotation?: number;
|
||||
lineType?: 'solid' | 'dashed' | 'dotted';
|
||||
radius?: number;
|
||||
x1?: number;
|
||||
y1?: number;
|
||||
x2?: number;
|
||||
y2?: number;
|
||||
points?: Array<{ x: number; y: number }>;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
startAngle?: number;
|
||||
endAngle?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CADElement {
|
||||
id: string;
|
||||
type: string;
|
||||
layerId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
properties: CADProperties;
|
||||
}
|
||||
|
||||
export interface ExportData {
|
||||
version: string;
|
||||
exportedAt: number;
|
||||
projectMeta: Record<string, unknown>;
|
||||
layers: CADLayer[];
|
||||
elements: CADElement[];
|
||||
}
|
||||
|
||||
export interface ImportData {
|
||||
version: string;
|
||||
exportedAt: number;
|
||||
projectMeta: Record<string, unknown>;
|
||||
layers: CADLayer[];
|
||||
elements: CADElement[];
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
includeHiddenLayers?: boolean;
|
||||
includeInvisibleElements?: boolean;
|
||||
pageSize?: 'a4' | 'a3' | 'letter';
|
||||
landscape?: boolean;
|
||||
}
|
||||
|
||||
/** Unified return type for all export functions. */
|
||||
export interface ExportResult {
|
||||
success: boolean;
|
||||
data?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:5000',
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
allowedHosts: ['cad.media-on.de', '.media-on.de'],
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
'/ws': {
|
||||
target: 'http://localhost:3001',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
globals: true
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user