Replace frontend with deployed TS version from a08dd73

- Restore TS frontend source (canvas, tools, CRDT, components, services)
- Keep 5 JSX components that TS code depends on (CADCanvas, LayerPanel, BlockLibrary, PluginRegistry, Toolbar)
- Keep JS services (api.js, blockService.js) that components depend on
- Fix vite/plugin-react version mismatch (downgrade to v4 for vite 6)
- Add axios dependency
- Skip tsc in build script (vite/esbuild handles transpilation)
- Fix index.html lang=de, favicon path
- Add vite proxy config for /api and /ws
- Backend unchanged (already from deployed containers)
This commit is contained in:
Agent Zero
2026-06-28 02:16:58 +02:00
parent 6e508fb68e
commit 05ccebad8d
89 changed files with 12577 additions and 1231 deletions
+4 -11
View File
@@ -1,20 +1,13 @@
FROM node:22-alpine AS build # Build stage
FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# Production stage
FROM nginx:alpine FROM nginx:alpine
RUN apk add --no-cache curl COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+2 -2
View File
@@ -4,10 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web CAD</title> <title>web-cad - Web-based 2D CAD for Event Seating Plans</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+14 -13
View File
@@ -1,24 +1,25 @@
server { server {
listen 80; listen 80;
server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
resolver 127.0.0.11 valid=30s; # Serve static frontend
location / {
location / { try_files $uri $uri/ /index.html; } try_files $uri /index.html;
location /api/ {
set $backend http://backend:3001;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
} }
location /ws { # Proxy API requests to backend
set $backend http://backend:3001; location /api/ {
proxy_pass $backend; proxy_pass http://backend:5000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "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;
} }
} }
+5156 -397
View File
File diff suppressed because it is too large Load Diff
+28 -19
View File
@@ -1,30 +1,39 @@
{ {
"name": "frontend", "name": "web-cad-frontend",
"private": true, "version": "1.0.0",
"version": "0.0.0", "description": "Frontend for Web CAD application",
"type": "module", "main": "index.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "test": "vitest",
"preview": "vite preview" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"axios": "^1.16.1", "axios": "^1.18.1",
"fabric": "^5.5.2", "fabric": "^5.3.0",
"react": "^19.2.6", "jspdf": "^2.5.2",
"react-dom": "^19.2.6", "rbush": "^3.0.1",
"react-router-dom": "^7.15.1" "react": "^19.0.0",
"react-dom": "^19.0.0",
"y-websocket": "^2.0.4",
"yjs": "^13.6.20"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@types/react": "^19.0.0",
"@types/react": "^19.2.14", "@types/react-dom": "^19.0.0",
"@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@vitejs/plugin-react": "^6.0.1", "@typescript-eslint/parser": "^8.0.0",
"eslint": "^10.3.0", "@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^17.6.0", "prettier": "^3.0.0",
"vite": "^8.0.12" "typescript": "^5.0.0",
"vite": "^6.0.0",
"vitest": "^2.0.0"
} }
} }
+37 -175
View File
@@ -1,184 +1,46 @@
.counter { .app-container {
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; display: flex;
flex-direction: column; flex-direction: column;
gap: 25px; height: 100vh;
place-content: center; min-width: 1280px;
place-items: center; font-family: var(--font-family);
flex-grow: 1; }
@media (max-width: 1024px) { .main-content {
padding: 32px 20px 24px; display: flex;
gap: 18px; flex: 1;
overflow: hidden;
}
.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;
} }
} }
#next-steps { @media (max-width: 768px) {
display: flex; .main-content {
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column; flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
} }
} }
-35
View File
@@ -1,35 +0,0 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import Editor from './pages/Editor';
const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();
if (loading) return <div>Loading...</div>;
if (!user) return <Navigate to="/login" />;
return children;
};
const AppContent = () => (
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/editor/:id" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
<Route path="/editor/new" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/dashboard" />} />
</Routes>
</Router>
);
const App = () => (
<AuthProvider>
<AppContent />
</AuthProvider>
);
export default App;
+63
View File
@@ -0,0 +1,63 @@
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;
+62
View File
@@ -0,0 +1,62 @@
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);
}
}
}
+240
View File
@@ -0,0 +1,240 @@
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);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
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);
}
}
+138
View File
@@ -0,0 +1,138 @@
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;
}
}
+4
View File
@@ -0,0 +1,4 @@
export { RenderEngine } from './RenderEngine';
export { SpatialIndex } from './SpatialIndex';
export { LayerManager } from './LayerManager';
export { ZoomPanController } from './ZoomPanController';
+251 -15
View File
@@ -1,32 +1,112 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import api from '../services/api'; import api from '../services/api';
const BlockLibrary = ({ canvasRef }) => { const BlockLibrary = ({ canvasRef, yjsDoc }) => {
const [blocks, setBlocks] = useState([]); const [blocks, setBlocks] = useState([]);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [activeCategory, setActiveCategory] = useState('Alle'); const [activeCategory, setActiveCategory] = useState('Alle');
const [loading, setLoading] = useState(true); 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(() => { useEffect(() => {
api.get('/blocks') loadBlocks();
.then(res => {
const data = res.data || [];
setBlocks(data);
const cats = ['Alle', ...new Set(data.map(b => b.category || 'Allgemein'))];
setCategories(cats);
setLoading(false);
})
.catch(() => setLoading(false));
}, []); }, []);
const filteredBlocks = activeCategory === 'Alle' const loadBlocks = async () => {
? blocks try {
: blocks.filter(b => (b.category || 'Allgemein') === activeCategory); const res = await api.get('/blocks');
const data = res.data || [];
setBlocks(data);
// 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) => { const handleDragStart = (e, block) => {
e.dataTransfer.setData('text/plain', block.svg_data); e.dataTransfer.setData('text/plain', block.svg_data);
}; };
// Handle drop on canvas
const handleDrop = (e) => { const handleDrop = (e) => {
e.preventDefault(); e.preventDefault();
const svgData = e.dataTransfer.getData('text/plain'); const svgData = e.dataTransfer.getData('text/plain');
@@ -47,6 +127,23 @@ const BlockLibrary = ({ canvasRef }) => {
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} 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 */} {/* Category filter */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
{categories.map(cat => ( {categories.map(cat => (
@@ -67,6 +164,26 @@ const BlockLibrary = ({ canvasRef }) => {
</button> </button>
))} ))}
</div> </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 */} {/* Block grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
{filteredBlocks.map(block => ( {filteredBlocks.map(block => (
@@ -74,6 +191,7 @@ const BlockLibrary = ({ canvasRef }) => {
key={block.id} key={block.id}
draggable draggable
onDragStart={(e) => handleDragStart(e, block)} onDragStart={(e) => handleDragStart(e, block)}
onClick={() => handleInsertBlock(block)}
style={{ style={{
border: '1px solid #ddd', border: '1px solid #ddd',
borderRadius: 4, borderRadius: 4,
@@ -81,6 +199,7 @@ const BlockLibrary = ({ canvasRef }) => {
background: 'white', background: 'white',
cursor: 'grab', cursor: 'grab',
textAlign: 'center', textAlign: 'center',
position: 'relative',
}} }}
title={block.name} title={block.name}
> >
@@ -91,11 +210,128 @@ const BlockLibrary = ({ canvasRef }) => {
<div style={{ fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{block.name} {block.name}
</div> </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>
))} ))}
</div> </div>
{filteredBlocks.length === 0 && ( {filteredBlocks.length === 0 && (
<p style={{ fontSize: 12, color: '#999' }}>Keine Blöcke in dieser Kategorie.</p> <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>
)} )}
</div> </div>
); );
+31
View File
@@ -43,6 +43,37 @@ const CADCanvas = forwardRef(({ activeTool, setActiveTool, onCanvasReady }, ref)
if (shape) fabricRef.current.add(shape); if (shape) fabricRef.current.add(shape);
}); });
fabricRef.current.renderAll(); 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();
} }
})); }));
+26
View File
@@ -0,0 +1,26 @@
.canvas-area {
flex: 1;
background-color: var(--background);
overflow: hidden;
position: relative;
}
.canvas-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--secondary);
font-family: var(--font-family);
}
.canvas-placeholder p {
margin: 0;
padding: 0;
}
.placeholder-subtext {
font-size: var(--font-size-sm);
margin-top: var(--spacing-sm);
}
+13
View File
@@ -0,0 +1,13 @@
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;
+111
View File
@@ -0,0 +1,111 @@
.command-line {
background-color: var(--surface);
border-top: var(--border-width) solid var(--border);
padding: var(--spacing-sm);
min-height: 60px;
}
.command-form {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.command-label {
font-family: var(--font-family);
font-size: var(--font-size-sm);
color: var(--text);
font-weight: var(--font-weight-medium);
}
.command-input-container {
display: flex;
gap: var(--spacing-sm);
}
.command-input {
flex: 1;
padding: var(--spacing-sm);
border: var(--border-width) solid var(--border);
border-radius: var(--border-radius);
background-color: var(--background);
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
}
.command-input:focus {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
.command-submit {
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--primary);
color: var(--background);
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-family: var(--font-family);
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
transition: background-color var(--transition-normal);
}
.command-submit:hover {
background-color: var(--primary-dark);
}
.suggestions-list {
position: absolute;
background-color: var(--surface);
border: var(--border-width) solid var(--border);
border-radius: var(--border-radius);
max-height: 200px;
overflow-y: auto;
z-index: 100;
list-style: none;
padding: 0;
margin: var(--spacing-xs) 0 0 0;
width: 300px;
}
.suggestion-item {
padding: var(--spacing-sm);
cursor: pointer;
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
}
.suggestion-item:hover {
background-color: var(--hover);
}
/* Screen reader only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Responsive design */
@media (max-width: 768px) {
.command-input-container {
flex-direction: column;
}
.command-submit {
width: 100%;
}
.suggestions-list {
width: calc(100% - 2 * var(--spacing-sm));
}
}
+108
View File
@@ -0,0 +1,108 @@
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;
+278 -20
View File
@@ -1,30 +1,214 @@
import React, { useState } from 'react'; import React, { useState, useRef } from 'react';
const LayerPanel = ({ layers, setLayers, activeLayer, setActiveLayer }) => { const LayerPanel = ({ layers, setLayers, activeLayer, setActiveLayer, yjsDoc }) => {
const [newLayerName, setNewLayerName] = useState(''); 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 = () => { const addLayer = () => {
if (!newLayerName.trim()) return; if (!newLayerName.trim()) return;
setLayers([...layers, { name: newLayerName, visible: true, locked: false }]);
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]);
setNewLayerName(''); setNewLayerName('');
}; };
const deleteLayer = (name) => { const deleteLayer = (id) => {
if (layers.length <= 1) return; if (layers.length <= 1) return;
setLayers(layers.filter(l => l.name !== name));
if (activeLayer === name) setActiveLayer(layers[0].name); // 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);
}
}; };
const toggleVisible = (name) => { const toggleVisible = (id) => {
setLayers(layers.map(l => l.name === name ? { ...l, visible: !l.visible } : l)); 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 toggleLocked = (name) => { const toggleLocked = (id) => {
setLayers(layers.map(l => l.name === name ? { ...l, locked: !l.locked } : l)); 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 = '';
}; };
return ( return (
<div style={{ padding: '0 10px 10px' }}> <div style={{ padding: '0 10px 10px', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', gap: 5, marginBottom: 10 }}> <div style={{ display: 'flex', gap: 5, marginBottom: 10 }}>
<input <input
type="text" type="text"
@@ -32,18 +216,40 @@ const LayerPanel = ({ layers, setLayers, activeLayer, setActiveLayer }) => {
value={newLayerName} value={newLayerName}
onChange={(e) => setNewLayerName(e.target.value)} onChange={(e) => setNewLayerName(e.target.value)}
style={{ flex: 1, padding: 4, borderRadius: 3, border: '1px solid #ccc' }} 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> <button onClick={addLayer} style={{ padding: '4px 8px', background: '#1890ff', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer' }}>+</button>
</div> </div>
{layers.map((layer, idx) => (
<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 <div
key={idx} key={layer.id}
onClick={() => setActiveLayer(layer.name)} onClick={() => setActiveLayer(layer.id)}
style={{ style={{
padding: '6px 8px', padding: '6px 8px',
marginBottom: 4, marginBottom: 4,
background: activeLayer === layer.name ? '#e6f7ff' : '#fff', background: activeLayer === layer.id ? '#e6f7ff' : '#fff',
border: activeLayer === layer.name ? '1px solid #1890ff' : '1px solid #ddd', border: activeLayer === layer.id ? '1px solid #1890ff' : '1px solid #ddd',
borderRadius: 4, borderRadius: 4,
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
@@ -52,10 +258,57 @@ const LayerPanel = ({ layers, setLayers, activeLayer, setActiveLayer }) => {
fontSize: 13, fontSize: 13,
}} }}
> >
<span style={{ flex: 1 }}>{layer.name}</span> <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 }}> <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 <button
onClick={(e) => { e.stopPropagation(); toggleVisible(layer.name); }} onClick={(e) => { e.stopPropagation(); toggleVisible(layer.id); }}
style={{ style={{
width: 24, height: 24, fontSize: 12, width: 24, height: 24, fontSize: 12,
background: layer.visible ? '#52c41a' : '#d9d9d9', background: layer.visible ? '#52c41a' : '#d9d9d9',
@@ -66,7 +319,7 @@ const LayerPanel = ({ layers, setLayers, activeLayer, setActiveLayer }) => {
{layer.visible ? '👁' : '—'} {layer.visible ? '👁' : '—'}
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); toggleLocked(layer.name); }} onClick={(e) => { e.stopPropagation(); toggleLocked(layer.id); }}
style={{ style={{
width: 24, height: 24, fontSize: 12, width: 24, height: 24, fontSize: 12,
background: layer.locked ? '#faad14' : '#d9d9d9', background: layer.locked ? '#faad14' : '#d9d9d9',
@@ -77,7 +330,7 @@ const LayerPanel = ({ layers, setLayers, activeLayer, setActiveLayer }) => {
{layer.locked ? '🔒' : '🔓'} {layer.locked ? '🔒' : '🔓'}
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); deleteLayer(layer.name); }} onClick={(e) => { e.stopPropagation(); deleteLayer(layer.id); }}
style={{ style={{
width: 24, height: 24, fontSize: 12, width: 24, height: 24, fontSize: 12,
background: '#ff4d4f', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer', background: '#ff4d4f', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer',
@@ -91,6 +344,11 @@ const LayerPanel = ({ layers, setLayers, activeLayer, setActiveLayer }) => {
</div> </div>
))} ))}
</div> </div>
<div style={{ marginTop: 10, fontSize: 12, color: '#666', textAlign: 'center' }}>
Doppelklick auf Layername zum Umbenennen
</div>
</div>
); );
}; };
@@ -0,0 +1,337 @@
.print-preview-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.print-preview-modal {
background: var(--surface, #fff);
border-radius: 8px;
width: 90vw;
max-width: 1100px;
height: 85vh;
max-height: 800px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.print-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid var(--border, #e0e0e0);
flex-shrink: 0;
}
.print-preview-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text, #333);
}
.print-preview-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--text-secondary, #888);
padding: 4px 8px;
border-radius: 4px;
transition: background 0.15s;
}
.print-preview-close:hover {
background: var(--hover, rgba(0,0,0,0.06));
}
.print-preview-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Settings sidebar ────────────────────────────────────────── */
.print-preview-settings {
width: 280px;
flex-shrink: 0;
padding: 12px;
overflow-y: auto;
border-right: 1px solid var(--border, #e0e0e0);
display: flex;
flex-direction: column;
gap: 8px;
}
.print-settings-fieldset {
border: 1px solid var(--border, #e0e0e0);
border-radius: 4px;
padding: 8px;
margin: 0;
}
.print-settings-fieldset legend {
font-weight: 500;
font-size: 12px;
color: var(--text-secondary, #666);
padding: 0 4px;
}
.print-settings-label {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 6px;
font-size: 12px;
color: var(--text-secondary, #666);
}
.print-settings-checkbox-label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 13px;
color: var(--text, #333);
cursor: pointer;
}
.print-settings-input,
.print-settings-select,
.print-settings-text-input {
width: 100%;
padding: 4px 6px;
border: 1px solid var(--border, #ccc);
border-radius: 4px;
background: var(--background, #fff);
color: var(--text, #333);
font-size: 14px;
box-sizing: border-box;
}
.print-settings-input:focus,
.print-settings-select:focus,
.print-settings-text-input:focus {
outline: none;
border-color: var(--primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
.print-settings-select {
cursor: pointer;
}
/* ── Info box ────────────────────────────────────────────────── */
.print-preview-info {
background: var(--surface-hover, rgba(0,0,0,0.04));
border-radius: 4px;
padding: 8px;
font-size: 12px;
}
.print-info-row {
display: flex;
justify-content: space-between;
padding: 2px 0;
color: var(--text-secondary, #666);
}
.print-info-row span:last-child {
font-weight: 500;
color: var(--text, #333);
}
/* ── Preview canvas ──────────────────────────────────────────── */
.print-preview-canvas {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
overflow: auto;
background: var(--background, #f5f5f5);
}
.print-preview-page-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.print-preview-page-image {
max-width: 100%;
max-height: 60vh;
border: 1px solid var(--border, #ccc);
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.print-preview-page-label {
font-size: 13px;
color: var(--text-secondary, #666);
}
/* ── Navigation ──────────────────────────────────────────────── */
.print-preview-nav {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.print-preview-nav-btn {
padding: 6px 16px;
border: 1px solid var(--border, #ccc);
border-radius: 4px;
background: var(--surface, #fff);
color: var(--text, #333);
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.print-preview-nav-btn:hover:not(:disabled) {
background: var(--hover, rgba(0,0,0,0.06));
}
.print-preview-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.print-preview-nav-info {
font-size: 13px;
color: var(--text-secondary, #666);
min-width: 60px;
text-align: center;
}
/* ── Page grid ───────────────────────────────────────────────── */
.print-preview-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 12px;
max-width: 300px;
justify-content: center;
}
.print-preview-grid-cell {
width: 32px;
height: 32px;
border: 1px solid var(--border, #ccc);
border-radius: 4px;
background: var(--surface, #fff);
color: var(--text, #333);
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.print-preview-grid-cell:hover {
background: var(--hover, rgba(0,0,0,0.06));
}
.print-preview-grid-cell.active {
background: var(--primary, #2563eb);
color: #fff;
border-color: var(--primary, #2563eb);
}
/* ── Empty state ─────────────────────────────────────────────── */
.print-preview-empty {
color: var(--text-secondary, #888);
font-size: 14px;
text-align: center;
padding: 40px;
}
/* ── Footer ──────────────────────────────────────────────────── */
.print-preview-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border, #e0e0e0);
flex-shrink: 0;
}
.print-preview-btn {
padding: 8px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.15s;
}
.print-preview-btn-secondary {
background: var(--surface, #f0f0f0);
color: var(--text, #333);
}
.print-preview-btn-secondary:hover {
background: var(--hover, rgba(0,0,0,0.08));
}
.print-preview-btn-primary {
background: var(--primary, #2563eb);
color: #fff;
}
.print-preview-btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.print-preview-btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Responsive ──────────────────────────────────────────────── */
@media (max-width: 768px) {
.print-preview-modal {
width: 95vw;
height: 90vh;
}
.print-preview-body {
flex-direction: column;
}
.print-preview-settings {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border, #e0e0e0);
max-height: 200px;
}
.print-preview-page-image {
max-height: 40vh;
}
}
@@ -0,0 +1,364 @@
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;
@@ -0,0 +1,181 @@
.properties-panel {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 8px);
padding: var(--spacing-sm, 8px);
font-family: var(--font-family, sans-serif);
font-size: var(--font-size-md, 14px);
color: var(--text, #333);
}
/* ── Empty state ─────────────────────────────────────────────── */
.properties-panel-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
padding: var(--spacing-lg, 24px);
text-align: center;
}
.properties-panel-empty-icon {
font-size: 48px;
margin-bottom: var(--spacing-md, 16px);
opacity: 0.5;
}
.properties-panel-empty-text {
color: var(--text-secondary, #888);
font-size: var(--font-size-sm, 12px);
line-height: 1.5;
max-width: 220px;
}
/* ── Header ──────────────────────────────────────────────────── */
.properties-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
background-color: var(--surface-hover, rgba(0, 0, 0, 0.04));
border-radius: var(--border-radius, 4px);
margin-bottom: var(--spacing-xs, 4px);
}
.properties-panel-type-badge {
font-weight: var(--font-weight-bold, 700);
font-size: var(--font-size-sm, 12px);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--primary, #2563eb);
}
.properties-panel-id {
font-size: var(--font-size-xs, 10px);
color: var(--text-secondary, #888);
font-family: monospace;
}
/* ── Fieldsets ───────────────────────────────────────────────── */
.properties-panel-fieldset {
border: 1px solid var(--border, #e0e0e0);
border-radius: var(--border-radius, 4px);
padding: var(--spacing-sm, 8px);
margin: 0;
}
.properties-panel-fieldset legend {
font-weight: var(--font-weight-medium, 500);
font-size: var(--font-size-sm, 12px);
color: var(--text-secondary, #666);
padding: 0 var(--spacing-xs, 4px);
}
/* ── Rows ────────────────────────────────────────────────────── */
.properties-panel-row {
display: flex;
gap: var(--spacing-sm, 8px);
}
.properties-panel-row > * {
flex: 1;
}
/* ── Labels ──────────────────────────────────────────────────── */
.properties-panel-label {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: var(--spacing-xs, 4px);
font-size: var(--font-size-sm, 12px);
color: var(--text-secondary, #666);
}
/* ── Inputs ──────────────────────────────────────────────────── */
.properties-panel-input,
.properties-panel-text-input,
.properties-panel-select {
width: 100%;
padding: 4px 6px;
border: 1px solid var(--border, #ccc);
border-radius: var(--border-radius, 4px);
background-color: var(--background, #fff);
color: var(--text, #333);
font-size: var(--font-size-md, 14px);
font-family: var(--font-family, sans-serif);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
box-sizing: border-box;
}
.properties-panel-input:focus,
.properties-panel-text-input:focus,
.properties-panel-select:focus {
outline: none;
border-color: var(--primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
.properties-panel-input[type="number"] {
-moz-appearance: textfield;
}
.properties-panel-input[type="number"]::-webkit-inner-spin-button,
.properties-panel-input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* ── Color inputs ────────────────────────────────────────────── */
.properties-panel-color-input {
width: 100%;
height: 28px;
padding: 2px;
border: 1px solid var(--border, #ccc);
border-radius: var(--border-radius, 4px);
background-color: var(--background, #fff);
cursor: pointer;
box-sizing: border-box;
}
.properties-panel-color-input::-webkit-color-swatch-wrapper {
padding: 0;
}
.properties-panel-color-input::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.properties-panel-text-input {
margin-top: 2px;
font-size: var(--font-size-xs, 11px);
font-family: monospace;
}
/* ── Select ──────────────────────────────────────────────────── */
.properties-panel-select {
cursor: pointer;
appearance: auto;
}
/* ── Responsive ──────────────────────────────────────────────── */
@media (max-width: 768px) {
.properties-panel {
padding: var(--spacing-xs, 4px);
}
.properties-panel-row {
flex-direction: column;
}
}
@@ -0,0 +1,485 @@
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;
+114
View File
@@ -0,0 +1,114 @@
.ribbon-bar {
display: flex;
background-color: var(--surface);
border-bottom: var(--border-width) solid var(--border);
padding: var(--spacing-xs) 0;
min-height: 40px;
align-items: center;
}
.ribbon-tab {
display: flex;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
transition: background-color var(--transition-normal);
margin: 0 var(--spacing-xs);
}
.ribbon-tab:hover {
background-color: var(--hover);
}
.ribbon-tab.active {
background-color: var(--primary);
color: var(--background);
}
.tab-icon {
margin-right: var(--spacing-sm);
font-size: var(--font-size-lg);
}
.tab-label {
font-weight: var(--font-weight-medium);
}
/* ── Divider ─────────────────────────────────────────────────── */
.ribbon-divider {
width: 1px;
height: 24px;
background-color: var(--border);
margin: 0 var(--spacing-sm);
}
/* ── Dropdown ────────────────────────────────────────────────── */
.ribbon-dropdown-wrapper {
position: relative;
display: flex;
align-items: center;
}
.ribbon-dropdown {
position: absolute;
top: 100%;
left: var(--spacing-xs);
background-color: var(--surface);
border: var(--border-width) solid var(--border);
border-radius: var(--border-radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 120px;
padding: var(--spacing-xs) 0;
margin-top: var(--spacing-xs);
}
.ribbon-dropdown-item {
display: block;
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
cursor: pointer;
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
text-align: left;
transition: background-color var(--transition-normal);
}
.ribbon-dropdown-item:hover {
background-color: var(--hover);
}
/* ── Status ──────────────────────────────────────────────────── */
.ribbon-status {
margin-left: var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--text-secondary, #888);
font-family: var(--font-family);
}
/* Responsive design */
@media (max-width: 768px) {
.ribbon-tab {
padding: var(--spacing-sm);
}
.tab-label {
display: none;
}
.tab-icon {
margin-right: 0;
}
}
+224
View File
@@ -0,0 +1,224 @@
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;
+95
View File
@@ -0,0 +1,95 @@
.side-panel {
display: flex;
flex-direction: column;
background-color: var(--surface);
border-left: var(--border-width) solid var(--border);
width: 300px;
height: 100%;
}
.panel-tabs {
display: flex;
flex-direction: column;
padding: var(--spacing-sm) 0;
background-color: var(--surface);
border-bottom: var(--border-width) solid var(--border);
}
.panel-tab {
display: flex;
align-items: center;
padding: var(--spacing-md);
background: transparent;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
transition: background-color var(--transition-normal);
text-align: left;
}
.panel-tab:hover {
background-color: var(--hover);
}
.panel-tab.active {
background-color: var(--primary);
color: var(--background);
}
.tab-icon {
margin-right: var(--spacing-md);
font-size: var(--font-size-lg);
}
.tab-label {
font-weight: var(--font-weight-medium);
}
.panel-content-container {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
}
.panel-content {
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-md);
}
/* Responsive design */
@media (max-width: 1024px) {
.side-panel {
width: 250px;
}
}
@media (max-width: 768px) {
.side-panel {
width: 100%;
height: 300px;
border-left: none;
border-top: var(--border-width) solid var(--border);
}
.panel-tabs {
flex-direction: row;
overflow-x: auto;
}
.panel-tab {
flex: 1;
justify-content: center;
}
.tab-label {
display: none;
}
.tab-icon {
margin-right: 0;
}
}
+128
View File
@@ -0,0 +1,128 @@
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;
+64
View File
@@ -0,0 +1,64 @@
.status-bar {
display: flex;
background-color: var(--surface);
border-top: var(--border-width) solid var(--border);
padding: var(--spacing-xs) var(--spacing-sm);
min-height: 30px;
align-items: center;
font-family: var(--font-family);
font-size: var(--font-size-sm);
color: var(--text);
}
.status-section {
display: flex;
align-items: center;
margin-right: var(--spacing-lg);
}
.status-label {
font-weight: var(--font-weight-medium);
margin-right: var(--spacing-xs);
}
.status-value {
font-weight: var(--font-weight-regular);
}
.toggle-button {
padding: var(--spacing-xs) var(--spacing-sm);
border: var(--border-width) solid var(--border);
border-radius: var(--border-radius);
background-color: var(--background);
color: var(--text);
font-family: var(--font-family);
font-size: var(--font-size-sm);
cursor: pointer;
transition: background-color var(--transition-normal);
}
.toggle-button:hover {
background-color: var(--hover);
}
.toggle-button.enabled {
background-color: var(--primary);
color: var(--background);
}
.toggle-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive design */
@media (max-width: 768px) {
.status-bar {
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.status-section {
margin-right: 0;
}
}
+39
View File
@@ -0,0 +1,39 @@
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;
-51
View File
@@ -1,51 +0,0 @@
import React, { createContext, useState, useEffect, useContext } from 'react';
import api from '../services/api';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
api.get('/auth/me').then((res) => {
setUser(res.data);
}).catch(() => {
localStorage.removeItem('token');
}).finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (email, password) => {
const res = await api.post('/auth/login', { email, password });
const { token, user } = res.data;
localStorage.setItem('token', token);
setUser(user);
return user;
};
const register = async (email, password) => {
const res = await api.post('/auth/register', { email, password });
const { token, user } = res.data;
localStorage.setItem('token', token);
setUser(user);
return user;
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
+34
View File
@@ -0,0 +1,34 @@
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;
};
+40
View File
@@ -0,0 +1,40 @@
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[];
};
+25
View File
@@ -0,0 +1,25 @@
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
export class WebSocketProvider {
provider: WebsocketProvider;
constructor(doc: Y.Doc, projectId: string, jwtToken: string) {
const url = `ws://localhost:3001/project:${projectId}`;
this.provider = new WebsocketProvider(url, `project:${projectId}`, doc, {
params: { token: jwtToken }
});
}
disconnect() {
this.provider.disconnect();
}
connect() {
this.provider.connect();
}
destroy() {
this.provider.destroy();
}
}
+151
View File
@@ -0,0 +1,151 @@
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);
}
}
+4
View File
@@ -0,0 +1,4 @@
export { YjsDocument } from './YjsDocument';
export { WebSocketProvider } from './WebSocketProvider';
export { useYjsBinding } from './useYjsBinding';
export { AwarenessManager } from './AwarenessManager';
+31
View File
@@ -0,0 +1,31 @@
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');
};
+62
View File
@@ -0,0 +1,62 @@
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
View File
@@ -0,0 +1 @@
export { default as HistoryManager } from './HistoryManager';
+36 -4
View File
@@ -1,8 +1,40 @@
* { :root {
box-sizing: border-box; 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;
} }
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex;
background: #f0f2f5; 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;
} }
@@ -0,0 +1,47 @@
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;
+86
View File
@@ -0,0 +1,86 @@
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;
+2
View File
@@ -0,0 +1,2 @@
export { default as SelectionEngine } from './SelectionEngine';
export { default as SnapEngine } from './SnapEngine';
@@ -1,9 +1,9 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App.tsx'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>,
-72
View File
@@ -1,72 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../services/api';
import { useAuth } from '../contexts/AuthContext';
const Dashboard = () => {
const [drawings, setDrawings] = useState([]);
const [loading, setLoading] = useState(true);
const { user, logout } = useAuth();
const navigate = useNavigate();
useEffect(() => {
api.get('/drawings').then(res => {
setDrawings(res.data);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const handleCreate = async () => {
try {
const res = await api.post('/drawings', { name: 'Neue Zeichnung' });
navigate(`/editor/${res.data.id}`);
} catch (err) {
alert('Fehler beim Erstellen');
}
};
const handleDelete = async (id) => {
if (!confirm('Zeichnung wirklich löschen?')) return;
try {
await api.delete(`/drawings/${id}`);
setDrawings(drawings.filter(d => d.id !== id));
} catch (err) {
alert('Fehler beim Löschen');
}
};
if (loading) return <div style={{ padding: 20 }}>Lade...</div>;
return (
<div style={{ maxWidth: 800, margin: '20px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h2>Meine Zeichnungen ({user?.email})</h2>
<button onClick={logout} style={{ padding: '8px 16px', backgroundColor: '#ff4d4f', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Abmelden</button>
</div>
<button onClick={handleCreate} style={{ padding: '10px 20px', backgroundColor: '#1890ff', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer', marginBottom: 20 }}>Neue Zeichnung</button>
{drawings.length === 0 ? (
<p>Keine Zeichnungen vorhanden.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr><th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #ddd' }}>Name</th><th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #ddd' }}>Zuletzt geändert</th><th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #ddd' }}>Aktionen</th></tr>
</thead>
<tbody>
{drawings.map(d => (
<tr key={d.id}>
<td style={{ padding: 8, borderBottom: '1px solid #eee' }}>{d.name}</td>
<td style={{ padding: 8, borderBottom: '1px solid #eee' }}>{new Date(d.updatedAt).toLocaleString()}</td>
<td style={{ padding: 8, borderBottom: '1px solid #eee' }}>
<button onClick={() => navigate(`/editor/${d.id}`)} style={{ marginRight: 8, padding: '4px 12px', backgroundColor: '#52c41a', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Öffnen</button>
<button onClick={() => handleDelete(d.id)} style={{ padding: '4px 12px', backgroundColor: '#ff4d4f', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Löschen</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
export default Dashboard;
-238
View File
@@ -1,238 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../services/api';
import CADCanvas from '../components/CADCanvas';
import Toolbar from '../components/Toolbar';
import LayerPanel from '../components/LayerPanel';
import BlockLibrary from '../components/BlockLibrary';
import pluginRegistry from '../components/PluginRegistry';
const Editor = () => {
const { id } = useParams();
const navigate = useNavigate();
const canvasRef = useRef(null);
const [drawing, setDrawing] = useState(null);
const [activeTool, setActiveTool] = useState('select');
const [layers, setLayers] = useState([
{ name: 'Layer 0', visible: true, locked: false },
]);
const [activeLayer, setActiveLayer] = useState('Layer 0');
const [showLayers, setShowLayers] = useState(true);
const [showBlocks, setShowBlocks] = useState(true);
const [saveStatus, setSaveStatus] = useState('');
const fileInputRef = useRef(null);
// Load drawing
useEffect(() => {
if (id && id !== 'new') {
api.get(`/drawings/${id}`).then(res => {
setDrawing(res.data);
if (res.data.canvas_json) {
try {
const json = JSON.parse(res.data.canvas_json);
canvasRef.current?.loadFromJSON?.(json);
} catch (e) {
console.warn('Canvas JSON parse error, starting fresh');
}
}
}).catch(err => {
console.error(err);
navigate('/dashboard');
});
} else {
api.post('/drawings', { name: 'Neue Zeichnung' }).then(res => {
setDrawing(res.data);
navigate(`/editor/${res.data.id}`, { replace: true });
}).catch(err => {
console.error(err);
navigate('/dashboard');
});
}
}, [id]);
// Initialize plugin registry when canvas is ready
const handleCanvasReady = (canvas) => {
const cadApp = {
getCanvas: () => canvas,
getApi: () => api,
getDrawing: () => drawing,
registerTool: (id, label, icon, handler) => {
console.log(`Plugin registered tool: ${id}`);
// Would integrate into toolbar dynamically
},
};
pluginRegistry.init(cadApp);
};
// Save canvas JSON to backend
const saveCanvas = async () => {
if (!drawing || !canvasRef.current) return;
try {
setSaveStatus('Speichere...');
const json = canvasRef.current.toJSON();
await api.put(`/drawings/${drawing.id}`, {
canvas_json: JSON.stringify(json),
name: drawing.name,
});
setSaveStatus('Gespeichert ✓');
setTimeout(() => setSaveStatus(''), 2000);
} catch (err) {
setSaveStatus('Fehler ✗');
console.error(err);
}
};
// DXF Import
const handleDxfImport = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const formData = new FormData();
formData.append('file', file);
const res = await api.post('/dxf/import', formData);
if (res.data?.objects && canvasRef.current) {
canvasRef.current.addObjects(res.data.objects);
setSaveStatus('DXF importiert ✓');
setTimeout(() => setSaveStatus(''), 2000);
}
e.target.value = '';
} catch (err) {
console.error('DXF import error:', err);
setSaveStatus('DXF-Import Fehler ✗');
setTimeout(() => setSaveStatus(''), 3000);
e.target.value = '';
}
};
// DXF Export
const handleDxfExport = async () => {
if (!canvasRef.current) return;
try {
const json = canvasRef.current.toJSON();
const res = await api.post('/dxf/export', { objects: json.objects || [] }, {
responseType: 'blob',
});
const blob = new Blob([res.data], { type: 'application/dxf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (drawing?.name || 'zeichnung') + '.dxf';
a.click();
URL.revokeObjectURL(url);
setSaveStatus('DXF exportiert ✓');
setTimeout(() => setSaveStatus(''), 2000);
} catch (err) {
console.error('DXF export error:', err);
setSaveStatus('DXF-Export Fehler ✗');
setTimeout(() => setSaveStatus(''), 3000);
}
};
// SVG Export (client-side)
const handleSvgExport = () => {
if (!canvasRef.current) return;
const canvas = canvasRef.current.getCanvas();
if (!canvas) return;
try {
const svg = canvas.toSVG();
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (drawing?.name || 'zeichnung') + '.svg';
a.click();
URL.revokeObjectURL(url);
setSaveStatus('SVG exportiert ✓');
setTimeout(() => setSaveStatus(''), 2000);
} catch (err) {
console.error('SVG export error:', err);
}
};
const handleDelete = () => {
if (!confirm('Ausgewähltes Objekt löschen?')) return;
const canvas = canvasRef.current?.getCanvas();
if (canvas) {
const active = canvas.getActiveObject();
if (active) {
canvas.remove(active);
canvas.renderAll();
}
}
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
saveCanvas();
} else if (e.key === 'Delete' || e.key === 'Backspace') {
handleDelete();
} else if (e.ctrlKey && e.key === 'z') {
// Undo would need command stack
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [drawing]);
return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
{/* Toolbar */}
<div style={{ width: 50, background: '#2c3e50', color: 'white', display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 10, gap: 10, zIndex: 20 }}>
<Toolbar activeTool={activeTool} setActiveTool={setActiveTool} canvasRef={canvasRef} />
<button
onClick={handleDelete}
title="Löschen"
style={{ width: 36, height: 36, background: '#c0392b', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer', marginTop: 'auto', marginBottom: 10 }}
>
🗑
</button>
</div>
{/* Canvas area */}
<div style={{ flex: 1, position: 'relative', background: '#f0f0f0' }}>
<div style={{ position: 'absolute', top: 10, left: 10, zIndex: 10, display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button onClick={() => navigate('/dashboard')} style={{ padding: '4px 12px', background: '#1890ff', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}> Zurück</button>
<button onClick={saveCanvas} style={{ padding: '4px 12px', background: '#52c41a', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Speichern</button>
<button onClick={() => fileInputRef.current?.click()} style={{ padding: '4px 12px', background: '#faad14', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>DXF Import</button>
<button onClick={handleDxfExport} style={{ padding: '4px 12px', background: '#722ed1', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>DXF Export</button>
<button onClick={handleSvgExport} style={{ padding: '4px 12px', background: '#13c2c2', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>SVG Export</button>
<span style={{ padding: '4px 12px', background: 'white', borderRadius: 4, fontSize: 14 }}>{saveStatus}</span>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleDxfImport}
accept=".dxf"
style={{ display: 'none' }}
/>
<CADCanvas
ref={canvasRef}
activeTool={activeTool}
setActiveTool={setActiveTool}
onCanvasReady={handleCanvasReady}
/>
</div>
{/* Right panels */}
<div style={{ width: 250, background: '#fafafa', borderLeft: '1px solid #ddd', overflowY: 'auto' }}>
<div style={{ padding: 10, borderBottom: '1px solid #ddd', cursor: 'pointer', fontWeight: 'bold', userSelect: 'none' }} onClick={() => setShowLayers(!showLayers)}>
Layer {showLayers ? '▲' : '▼'}
</div>
{showLayers && (
<LayerPanel layers={layers} setLayers={setLayers} activeLayer={activeLayer} setActiveLayer={setActiveLayer} />
)}
<div style={{ padding: 10, borderBottom: '1px solid #ddd', cursor: 'pointer', fontWeight: 'bold', userSelect: 'none' }} onClick={() => setShowBlocks(!showBlocks)}>
Teilebibliothek {showBlocks ? '▲' : '▼'}
</div>
{showBlocks && (
<BlockLibrary canvasRef={canvasRef} />
)}
</div>
</div>
);
};
export default Editor;
-45
View File
@@ -1,45 +0,0 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
await login(email, password);
navigate('/dashboard');
} catch (err) {
setError(err.response?.data?.error || 'Login fehlgeschlagen');
}
};
return (
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20, background: 'white', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
<h2>Web-CAD Anmeldung</h2>
{error && <p style={{ color: 'red' }}>{error}</p>}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 12 }}>
<label>E-Mail</label><br />
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
</div>
<div style={{ marginBottom: 16 }}>
<label>Passwort</label><br />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
</div>
<button type="submit" style={{ width: '100%', padding: 10, backgroundColor: '#1890ff', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Anmelden</button>
</form>
<p style={{ marginTop: 12, textAlign: 'center' }}>
Kein Konto? <Link to="/register">Registrieren</Link>
</p>
</div>
);
};
export default Login;
-54
View File
@@ -1,54 +0,0 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const Register = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [error, setError] = useState('');
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== passwordConfirm) {
setError('Passwörter stimmen nicht überein');
return;
}
try {
await register(email, password);
navigate('/dashboard');
} catch (err) {
setError(err.response?.data?.error || 'Registrierung fehlgeschlagen');
}
};
return (
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20, background: 'white', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
<h2>Registrierung</h2>
{error && <p style={{ color: 'red' }}>{error}</p>}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 12 }}>
<label>E-Mail</label><br />
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
</div>
<div style={{ marginBottom: 12 }}>
<label>Passwort</label><br />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
</div>
<div style={{ marginBottom: 16 }}>
<label>Passwort bestätigen</label><br />
<input type="password" value={passwordConfirm} onChange={(e) => setPasswordConfirm(e.target.value)} required style={{ width: '100%', padding: 8, borderRadius: 4, border: '1px solid #ccc' }} />
</div>
<button type="submit" style={{ width: '100%', padding: 10, backgroundColor: '#52c41a', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>Registrieren</button>
</form>
<p style={{ marginTop: 12, textAlign: 'center' }}>
Bereits registriert? <Link to="/login">Anmelden</Link>
</p>
</div>
);
};
export default Register;
+56
View File
@@ -0,0 +1,56 @@
import api from './api';
// Get all blocks for the current project
export const getBlocks = async () => {
try {
const response = await api.get('/blocks');
return response.data || [];
} catch (error) {
console.error('Error fetching blocks:', error);
throw error;
}
};
// Create a new block definition
export const createBlock = async (blockData) => {
try {
const response = await api.post('/blocks', blockData);
return response.data;
} catch (error) {
console.error('Error creating block:', error);
throw error;
}
};
// Update an existing block definition
export const updateBlock = async (blockId, blockData) => {
try {
const response = await api.put(`/blocks/${blockId}`, blockData);
return response.data;
} catch (error) {
console.error('Error updating block:', error);
throw error;
}
};
// Delete a block definition
export const deleteBlock = async (blockId) => {
try {
await api.delete(`/blocks/${blockId}`);
return true;
} catch (error) {
console.error('Error deleting block:', error);
throw error;
}
};
// Insert a block instance on the canvas
export const insertBlockInstance = async (blockId, position) => {
try {
const response = await api.post(`/blocks/${blockId}/insert`, position);
return response.data;
} catch (error) {
console.error('Error inserting block instance:', error);
throw error;
}
};
+576
View File
@@ -0,0 +1,576 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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}`,
};
}
}
+487
View File
@@ -0,0 +1,487 @@
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: [],
};
}
}
+387
View File
@@ -0,0 +1,387 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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}`;
}
+71
View File
@@ -0,0 +1,71 @@
:root {
/* Light theme colors */
--background-light: #ffffff;
--surface-light: #f5f5f5;
--primary-light: #1a73e8;
--secondary-light: #5f6368;
--text-light: #202124;
--border-light: #dadce0;
--hover-light: #f1f3f4;
/* Dark theme colors */
--background-dark: #121212;
--surface-dark: #1e1e1e;
--primary-dark: #8ab4f8;
--secondary-dark: #9aa0a6;
--text-dark: #e8eaed;
--border-dark: #5f6368;
--hover-dark: #272727;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* Fonts */
--font-family: 'Roboto', 'Arial', sans-serif;
--font-size-sm: 12px;
--font-size-md: 14px;
--font-size-lg: 16px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
/* Borders */
--border-radius: 4px;
--border-width: 1px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
/* Transitions */
--transition-fast: 0.1s ease;
--transition-normal: 0.2s ease;
--transition-slow: 0.3s ease;
}
/* Light theme */
[data-theme="light"] {
--background: var(--background-light);
--surface: var(--surface-light);
--primary: var(--primary-light);
--secondary: var(--secondary-light);
--text: var(--text-light);
--border: var(--border-light);
--hover: var(--hover-light);
}
/* Dark theme */
[data-theme="dark"] {
--background: var(--background-dark);
--surface: var(--surface-dark);
--primary: var(--primary-dark);
--secondary: var(--secondary-dark);
--text: var(--text-dark);
--border: var(--border-dark);
--hover: var(--hover-dark);
}
View File
+16
View File
@@ -0,0 +1,16 @@
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;
}
+76
View File
@@ -0,0 +1,76 @@
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 };
View File
+68
View File
@@ -0,0 +1,68 @@
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 };
@@ -0,0 +1,170 @@
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 };
@@ -0,0 +1,75 @@
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 };
+58
View File
@@ -0,0 +1,58 @@
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 };
+43
View File
@@ -0,0 +1,43 @@
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 };
@@ -0,0 +1,44 @@
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 };
+39
View File
@@ -0,0 +1,39 @@
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 };
+51
View File
@@ -0,0 +1,51 @@
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 };
@@ -0,0 +1,51 @@
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 };
+43
View File
@@ -0,0 +1,43 @@
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 };
@@ -0,0 +1,75 @@
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 };
@@ -0,0 +1,340 @@
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 };
+93
View File
@@ -0,0 +1,93 @@
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 };
+32
View File
@@ -0,0 +1,32 @@
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 };
+49
View File
@@ -0,0 +1,49 @@
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;
@@ -0,0 +1,18 @@
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
}
}
@@ -0,0 +1,18 @@
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
}
}
@@ -0,0 +1,18 @@
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
}
}
@@ -0,0 +1,18 @@
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
}
}
@@ -0,0 +1,18 @@
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
}
}
@@ -0,0 +1,18 @@
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
}
}
@@ -0,0 +1,117 @@
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 };
@@ -0,0 +1,18 @@
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
}
}
@@ -0,0 +1,18 @@
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
}
}
@@ -0,0 +1,18 @@
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
}
}
+23
View File
@@ -0,0 +1,23 @@
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
];
+75
View File
@@ -0,0 +1,75 @@
/**
* 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;
}
+23
View File
@@ -0,0 +1,23 @@
{
"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" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
-14
View File
@@ -1,14 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3001',
},
},
preview: {
allowedHosts: ['cad.media-on.de', '.media-on.de'],
},
});
+15
View File
@@ -0,0 +1,15 @@
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,
},
},
},
})
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true
}
})