From 7b19a99b24835876fb2a94382d3d38eeb9203f5d Mon Sep 17 00:00:00 2001 From: A0 Orchestrator Date: Wed, 1 Jul 2026 21:02:31 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20global=20block=20library=20tree=20+=20g?= =?UTF-8?q?rouping=20tool=20=E2=80=94=2019=20files,=20588=20tests=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/database/DatabaseInterface.ts | 31 ++ backend/src/database/SqliteAdapter.ts | 78 ++++ backend/src/database/schema.sql | 24 + backend/src/routes/globalBlocks.ts | 167 +++++++ backend/src/server.ts | 2 + backend/tests/globalBlocks.test.ts | 426 +++++++++++++++++ frontend/src/App.tsx | 67 ++- frontend/src/canvas/RenderEngine.ts | 52 +++ frontend/src/components/BlockLibraryTree.tsx | 463 +++++++++++++++++++ frontend/src/components/CanvasArea.tsx | 70 ++- frontend/src/components/LeftSidebar.tsx | 32 +- frontend/src/components/RibbonBar.tsx | 8 + frontend/src/components/RightSidebar.tsx | 16 +- frontend/src/interaction/index.ts | 56 ++- frontend/src/services/api.ts | 96 ++++ frontend/src/styles.css | 142 ++++++ frontend/src/tools/modification/GroupTool.ts | 13 + frontend/src/types/ui.types.ts | 4 + frontend/tests/globalBlocks.test.ts | 250 ++++++++++ test_report.md | 181 +++++--- 20 files changed, 2099 insertions(+), 79 deletions(-) create mode 100644 backend/src/routes/globalBlocks.ts create mode 100644 backend/tests/globalBlocks.test.ts create mode 100644 frontend/src/components/BlockLibraryTree.tsx create mode 100644 frontend/tests/globalBlocks.test.ts diff --git a/backend/src/database/DatabaseInterface.ts b/backend/src/database/DatabaseInterface.ts index d1798e5..367a9f3 100644 --- a/backend/src/database/DatabaseInterface.ts +++ b/backend/src/database/DatabaseInterface.ts @@ -99,6 +99,23 @@ export interface DBProjectShare { created_at: string; } +export interface DBGlobalBlockFolder { + id: string; + name: string; + parent_id: string | null; + created_at: string; +} + +export interface DBGlobalBlock { + id: string; + folder_id: string | null; + name: string; + block_data: string; + svg_data: string | null; + created_at: string; + updated_at: string; +} + export interface DatabaseInterface { init(): Promise; close(): void; @@ -165,4 +182,18 @@ export interface DatabaseInterface { getProjectShare(id: string): DBProjectShare | null; createProjectShare(data: Partial): DBProjectShare; deleteProjectShare(id: string): boolean; + + // Global Block Folders + listGlobalFolders(parentId?: string | null): DBGlobalBlockFolder[]; + getGlobalFolder(id: string): DBGlobalBlockFolder | null; + createGlobalFolder(data: Partial): DBGlobalBlockFolder; + updateGlobalFolder(id: string, data: Partial): DBGlobalBlockFolder | null; + deleteGlobalFolder(id: string): boolean; + + // Global Blocks + listGlobalBlocks(folderId?: string | null): DBGlobalBlock[]; + getGlobalBlock(id: string): DBGlobalBlock | null; + createGlobalBlock(data: Partial): DBGlobalBlock; + updateGlobalBlock(id: string, data: Partial): DBGlobalBlock | null; + deleteGlobalBlock(id: string): boolean; } diff --git a/backend/src/database/SqliteAdapter.ts b/backend/src/database/SqliteAdapter.ts index 57eee0a..f6573f4 100644 --- a/backend/src/database/SqliteAdapter.ts +++ b/backend/src/database/SqliteAdapter.ts @@ -10,6 +10,7 @@ import type { DatabaseInterface, DBProject, DBDrawing, DBLayer, DBElement, DBBlock, DBSetting, DBUser, DBSession, DBNotification, DBProjectShare, + DBGlobalBlockFolder, DBGlobalBlock, } from './DatabaseInterface.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -319,4 +320,81 @@ export class SqliteAdapter implements DatabaseInterface { deleteProjectShare(id: string): boolean { return this.db.prepare('DELETE FROM project_shares WHERE id = ?').run(id).changes > 0; } + + // ─── Global Block Folders ────────────────────────────── + listGlobalFolders(parentId?: string | null): DBGlobalBlockFolder[] { + if (parentId === undefined) { + return this.db.prepare('SELECT * FROM global_block_folders ORDER BY name').all() as DBGlobalBlockFolder[]; + } + if (parentId === null) { + return this.db.prepare('SELECT * FROM global_block_folders WHERE parent_id IS NULL ORDER BY name').all() as DBGlobalBlockFolder[]; + } + return this.db.prepare('SELECT * FROM global_block_folders WHERE parent_id = ? ORDER BY name').all(parentId) as DBGlobalBlockFolder[]; + } + + getGlobalFolder(id: string): DBGlobalBlockFolder | null { + return (this.db.prepare('SELECT * FROM global_block_folders WHERE id = ?').get(id) as DBGlobalBlockFolder) ?? null; + } + + createGlobalFolder(data: Partial): DBGlobalBlockFolder { + const id = data.id ?? `gfolder-${Date.now()}`; + this.db.prepare( + 'INSERT INTO global_block_folders (id, name, parent_id) VALUES (?, ?, ?)', + ).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null); + return this.getGlobalFolder(id)!; + } + + updateGlobalFolder(id: string, data: Partial): DBGlobalBlockFolder | null { + const sets: string[] = []; + const vals: unknown[] = []; + if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } + if (data.parent_id !== undefined) { sets.push('parent_id = ?'); vals.push(data.parent_id); } + if (sets.length > 0) { + this.db.prepare(`UPDATE global_block_folders SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); + } + return this.getGlobalFolder(id); + } + + deleteGlobalFolder(id: string): boolean { + return this.db.prepare('DELETE FROM global_block_folders WHERE id = ?').run(id).changes > 0; + } + + // ─── Global Blocks ───────────────────────────────────── + listGlobalBlocks(folderId?: string | null): DBGlobalBlock[] { + if (folderId === undefined) { + return this.db.prepare('SELECT * FROM global_blocks ORDER BY updated_at DESC').all() as DBGlobalBlock[]; + } + if (folderId === null) { + return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as DBGlobalBlock[]; + } + return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as DBGlobalBlock[]; + } + + getGlobalBlock(id: string): DBGlobalBlock | null { + return (this.db.prepare('SELECT * FROM global_blocks WHERE id = ?').get(id) as DBGlobalBlock) ?? null; + } + + createGlobalBlock(data: Partial): DBGlobalBlock { + const id = data.id ?? `gblock-${Date.now()}`; + this.db.prepare( + 'INSERT INTO global_blocks (id, folder_id, name, block_data, svg_data) VALUES (?, ?, ?, ?, ?)', + ).run(id, data.folder_id ?? null, data.name ?? 'Global Block', data.block_data ?? '{}', data.svg_data ?? null); + return this.getGlobalBlock(id)!; + } + + updateGlobalBlock(id: string, data: Partial): DBGlobalBlock | null { + const sets: string[] = []; + const vals: unknown[] = []; + if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } + if (data.folder_id !== undefined) { sets.push('folder_id = ?'); vals.push(data.folder_id); } + if (data.block_data !== undefined) { sets.push('block_data = ?'); vals.push(data.block_data); } + if (data.svg_data !== undefined) { sets.push('svg_data = ?'); vals.push(data.svg_data); } + sets.push("updated_at = datetime('now')"); + this.db.prepare(`UPDATE global_blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); + return this.getGlobalBlock(id); + } + + deleteGlobalBlock(id: string): boolean { + return this.db.prepare('DELETE FROM global_blocks WHERE id = ?').run(id).changes > 0; + } } diff --git a/backend/src/database/schema.sql b/backend/src/database/schema.sql index 2ea8047..19df6c1 100644 --- a/backend/src/database/schema.sql +++ b/backend/src/database/schema.sql @@ -133,3 +133,27 @@ CREATE INDEX IF NOT EXISTS idx_blocks_drawing ON blocks(drawing_id); CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id); CREATE INDEX IF NOT EXISTS idx_shares_project ON project_shares(project_id); + +-- ─── Global Block Folders ───────────────────────────── +CREATE TABLE IF NOT EXISTS global_block_folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + parent_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (parent_id) REFERENCES global_block_folders(id) ON DELETE CASCADE +); + +-- ─── Global Blocks ────────────────────────────────────── +CREATE TABLE IF NOT EXISTS global_blocks ( + id TEXT PRIMARY KEY, + folder_id TEXT, + name TEXT NOT NULL, + block_data TEXT NOT NULL DEFAULT '{}', + svg_data TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (folder_id) REFERENCES global_block_folders(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_global_blocks_folder ON global_blocks(folder_id); +CREATE INDEX IF NOT EXISTS idx_global_block_folders_parent ON global_block_folders(parent_id); diff --git a/backend/src/routes/globalBlocks.ts b/backend/src/routes/globalBlocks.ts new file mode 100644 index 0000000..31ebbba --- /dev/null +++ b/backend/src/routes/globalBlocks.ts @@ -0,0 +1,167 @@ +/** + * Global Blocks Routes – CRUD for global block folders and blocks + */ +import type { FastifyInstance } from 'fastify'; +import type { DatabaseInterface } from '../database/DatabaseInterface.js'; +import { requireAuth } from '../auth/authMiddleware.js'; +import type { AuthService } from '../auth/AuthService.js'; +import { validateName, validateIdParam } from '../utils/validation.js'; + +export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { + // ─── Global Block Folders ────────────────────────────── + + // List all folders, optionally filtered by parent + fastify.get('/api/global-folders', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const parentId = (request.query as { parentId?: string | null }).parentId; + if (parentId === undefined || parentId === '' || parentId === 'null') { + return db.listGlobalFolders(parentId === 'null' ? null : undefined); + } + const idErr = validateIdParam(parentId, 'parentId'); + if (idErr) return reply.code(400).send({ error: idErr }); + return db.listGlobalFolders(parentId); + }); + + // Get a single folder + fastify.get('/api/global-folders/:id', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const { id } = request.params as { id: string }; + const idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); + const folder = db.getGlobalFolder(id); + if (!folder) return reply.code(404).send({ error: 'Folder not found' }); + return folder; + }); + + // Create a new folder + fastify.post('/api/global-folders', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const body = request.body as { name?: string; parent_id?: string | null }; + const nameErr = validateName(body.name, 'name'); + if (nameErr) return reply.code(400).send({ error: nameErr }); + if (body.parent_id !== undefined && body.parent_id !== null) { + const pidErr = validateIdParam(body.parent_id, 'parent_id'); + if (pidErr) return reply.code(400).send({ error: pidErr }); + } + return reply.code(201).send(db.createGlobalFolder({ name: body.name, parent_id: body.parent_id ?? null })); + }); + + // Rename / move a folder + fastify.patch('/api/global-folders/:id', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const { id } = request.params as { id: string }; + const idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); + const body = request.body as { name?: string; parent_id?: string | null }; + if (body.name !== undefined) { + const nameErr = validateName(body.name, 'name'); + if (nameErr) return reply.code(400).send({ error: nameErr }); + } + if (body.parent_id !== undefined && body.parent_id !== null) { + const pidErr = validateIdParam(body.parent_id, 'parent_id'); + if (pidErr) return reply.code(400).send({ error: pidErr }); + // Prevent setting parent to self + if (body.parent_id === id) return reply.code(400).send({ error: 'Cannot set parent to self' }); + } + const updated = db.updateGlobalFolder(id, { name: body.name, parent_id: body.parent_id }); + if (!updated) return reply.code(404).send({ error: 'Folder not found' }); + return updated; + }); + + // Delete a folder (cascades to children, blocks get folder_id = NULL) + fastify.delete('/api/global-folders/:id', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const { id } = request.params as { id: string }; + const idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); + const ok = db.deleteGlobalFolder(id); + if (!ok) return reply.code(404).send({ error: 'Folder not found' }); + return reply.code(204).send(); + }); + + // ─── Global Blocks ──────────────────────────────────── + + // List all global blocks, optionally filtered by folder + fastify.get('/api/global-blocks', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const folderId = (request.query as { folderId?: string | null }).folderId; + if (folderId === undefined || folderId === '' || folderId === 'null') { + return db.listGlobalBlocks(folderId === 'null' ? null : undefined); + } + const idErr = validateIdParam(folderId, 'folderId'); + if (idErr) return reply.code(400).send({ error: idErr }); + return db.listGlobalBlocks(folderId); + }); + + // Get a single global block + fastify.get('/api/global-blocks/:id', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const { id } = request.params as { id: string }; + const idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); + const block = db.getGlobalBlock(id); + if (!block) return reply.code(404).send({ error: 'Global block not found' }); + return block; + }); + + // Create a new global block + fastify.post('/api/global-blocks', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string }; + const nameErr = validateName(body.name, 'name'); + if (nameErr) return reply.code(400).send({ error: nameErr }); + if (body.folder_id !== undefined && body.folder_id !== null) { + const fidErr = validateIdParam(body.folder_id, 'folder_id'); + if (fidErr) return reply.code(400).send({ error: fidErr }); + } + if (body.block_data !== undefined && typeof body.block_data !== 'string') { + return reply.code(400).send({ error: 'block_data must be a JSON string' }); + } + if (body.svg_data !== undefined && typeof body.svg_data !== 'string') { + return reply.code(400).send({ error: 'svg_data must be a string' }); + } + return reply.code(201).send(db.createGlobalBlock({ + name: body.name, + folder_id: body.folder_id ?? null, + block_data: body.block_data ?? '{}', + svg_data: body.svg_data ?? null, + })); + }); + + // Update a global block (rename, move, update data) + fastify.patch('/api/global-blocks/:id', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const { id } = request.params as { id: string }; + const idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); + const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string }; + if (body.name !== undefined) { + const nameErr = validateName(body.name, 'name'); + if (nameErr) return reply.code(400).send({ error: nameErr }); + } + if (body.folder_id !== undefined && body.folder_id !== null) { + const fidErr = validateIdParam(body.folder_id, 'folder_id'); + if (fidErr) return reply.code(400).send({ error: fidErr }); + } + if (body.block_data !== undefined && typeof body.block_data !== 'string') { + return reply.code(400).send({ error: 'block_data must be a JSON string' }); + } + if (body.svg_data !== undefined && typeof body.svg_data !== 'string') { + return reply.code(400).send({ error: 'svg_data must be a string' }); + } + const updated = db.updateGlobalBlock(id, body); + if (!updated) return reply.code(404).send({ error: 'Global block not found' }); + return updated; + }); + + // Delete a global block + fastify.delete('/api/global-blocks/:id', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const { id } = request.params as { id: string }; + const idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); + const ok = db.deleteGlobalBlock(id); + if (!ok) return reply.code(404).send({ error: 'Global block not found' }); + return reply.code(204).send(); + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 0323745..c6be285 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -66,6 +66,7 @@ export async function createServer(opts: ServerOptions) { const { registerAIRoutes } = await import('./routes/ai.js'); const { registerNotificationRoutes } = await import('./routes/notifications.js'); const { registerShareRoutes } = await import('./routes/shares.js'); + const { registerGlobalBlockRoutes } = await import('./routes/globalBlocks.js'); const authService = new AuthService(opts.db); @@ -100,6 +101,7 @@ export async function createServer(opts: ServerOptions) { registerAIRoutes(fastify, authService); registerNotificationRoutes(fastify, opts.db, authService); registerShareRoutes(fastify, opts.db, authService); + registerGlobalBlockRoutes(fastify, opts.db, authService); // Yjs collaboration WebSocket registerYjsWebSocket(fastify, authService); diff --git a/backend/tests/globalBlocks.test.ts b/backend/tests/globalBlocks.test.ts new file mode 100644 index 0000000..c0c719e --- /dev/null +++ b/backend/tests/globalBlocks.test.ts @@ -0,0 +1,426 @@ +/** + * Global Blocks API Tests – CRUD for folders and blocks + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; +import { createServer } from '../src/server.js'; + +describe('Global Blocks API', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let authToken: string; + let createdFolderId: string; + let subFolderId: string; + let createdBlockId: string; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + + const regRes = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: 'global-test@example.com', password: 'Password123!', name: 'Global Test', role: 'admin' }, + }); + authToken = JSON.parse(regRes.body).session.token; + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + // ─── Auth Check ────────────────────────────────────── + + describe('Authentication', () => { + it('should return 401 without authorization header for folders', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/global-folders', + }); + expect(response.statusCode).toBe(401); + }); + + it('should return 401 without authorization header for blocks', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/global-blocks', + }); + expect(response.statusCode).toBe(401); + }); + }); + + // ─── Folder CRUD ───────────────────────────────────── + + describe('POST /api/global-folders', () => { + it('should create a root folder with 201', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/global-folders', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Möbel' }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.id).toBeDefined(); + expect(body.name).toBe('Möbel'); + expect(body.parent_id).toBeNull(); + createdFolderId = body.id; + }); + + it('should create a sub-folder with parent_id', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/global-folders', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Stühle', parent_id: createdFolderId }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.name).toBe('Stühle'); + expect(body.parent_id).toBe(createdFolderId); + subFolderId = body.id; + }); + + it('should reject folder without name with 400', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/global-folders', + headers: { authorization: `Bearer ${authToken}` }, + payload: {}, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('Name is required'); + }); + + it('should reject folder with empty name with 400', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/global-folders', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: '' }, + }); + expect(response.statusCode).toBe(400); + }); + }); + + describe('GET /api/global-folders', () => { + it('should list all folders', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/global-folders', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(2); + }); + + it('should list root folders (parentId=null)', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/global-folders?parentId=null', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.every((f: { parent_id: string | null }) => f.parent_id === null)).toBe(true); + }); + + it('should list sub-folders for a parent', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/global-folders?parentId=${createdFolderId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.length).toBeGreaterThanOrEqual(1); + expect(body[0].parent_id).toBe(createdFolderId); + }); + }); + + describe('PATCH /api/global-folders/:id', () => { + it('should rename a folder', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/global-folders/${createdFolderId}`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Möbel & Ausstattung' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.name).toBe('Möbel & Ausstattung'); + }); + + it('should reject setting parent to self with 400', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/global-folders/${createdFolderId}`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { parent_id: createdFolderId }, + }); + expect(response.statusCode).toBe(400); + }); + + it('should return 404 for non-existent folder', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/global-folders/non-existent', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'New Name' }, + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ─── Block CRUD ────────────────────────────────────── + + describe('POST /api/global-blocks', () => { + it('should create a global block in a folder with 201', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/global-blocks', + headers: { authorization: `Bearer ${authToken}` }, + payload: { + name: 'Konferenzstuhl', + folder_id: subFolderId, + block_data: JSON.stringify({ type: 'chair', width: 50, height: 50 }), + svg_data: '', + }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.id).toBeDefined(); + expect(body.name).toBe('Konferenzstuhl'); + expect(body.folder_id).toBe(subFolderId); + expect(body.block_data).toContain('chair'); + expect(body.svg_data).toContain(''); + createdBlockId = body.id; + }); + + it('should create a global block without folder (root level)', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/global-blocks', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Root Block' }, + }); + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.folder_id).toBeNull(); + expect(body.block_data).toBe('{}'); + }); + + it('should reject block without name with 400', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/global-blocks', + headers: { authorization: `Bearer ${authToken}` }, + payload: { folder_id: createdFolderId }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('Name is required'); + }); + + it('should reject non-string block_data with 400', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/global-blocks', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Bad Block', block_data: { not: 'a string' } }, + }); + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).error).toContain('block_data must be a JSON string'); + }); + }); + + describe('GET /api/global-blocks', () => { + it('should list all global blocks', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/global-blocks', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(2); + }); + + it('should list blocks for a specific folder', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/global-blocks?folderId=${subFolderId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.length).toBeGreaterThanOrEqual(1); + expect(body.every((b: { folder_id: string | null }) => b.folder_id === subFolderId)).toBe(true); + }); + + it('should list root-level blocks (folderId=null)', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/global-blocks?folderId=null', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.every((b: { folder_id: string | null }) => b.folder_id === null)).toBe(true); + }); + }); + + describe('GET /api/global-blocks/:id', () => { + it('should get a single global block', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/global-blocks/${createdBlockId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.id).toBe(createdBlockId); + expect(body.name).toBe('Konferenzstuhl'); + }); + + it('should return 404 for non-existent block', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/global-blocks/non-existent', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(404); + }); + }); + + describe('PATCH /api/global-blocks/:id', () => { + it('should rename a block', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/global-blocks/${createdBlockId}`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Updated Chair' }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.name).toBe('Updated Chair'); + }); + + it('should move a block to a different folder', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/global-blocks/${createdBlockId}`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { folder_id: createdFolderId }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.folder_id).toBe(createdFolderId); + }); + + it('should update block_data', async () => { + const response = await app.inject({ + method: 'PATCH', + url: `/api/global-blocks/${createdBlockId}`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { block_data: JSON.stringify({ type: 'updated', width: 60 }) }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.block_data).toContain('updated'); + }); + + it('should return 404 for non-existent block update', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/global-blocks/non-existent', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'New Name' }, + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ─── Delete ─────────────────────────────────────────── + + describe('DELETE /api/global-blocks/:id', () => { + it('should delete a global block', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/global-blocks', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'To Delete' }, + }); + const blockId = JSON.parse(createRes.body).id; + + const response = await app.inject({ + method: 'DELETE', + url: `/api/global-blocks/${blockId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(204); + }); + + it('should return 404 for non-existent block delete', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/global-blocks/non-existent', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(404); + }); + }); + + describe('DELETE /api/global-folders/:id', () => { + it('should delete a folder and cascade to sub-folders', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/global-folders', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Temp Folder' }, + }); + const folderId = JSON.parse(createRes.body).id; + + const subRes = await app.inject({ + method: 'POST', + url: '/api/global-folders', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Temp Sub', parent_id: folderId }, + }); + const subId = JSON.parse(subRes.body).id; + + const response = await app.inject({ + method: 'DELETE', + url: `/api/global-folders/${folderId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(204); + + // Sub-folder should be gone (cascade) + const checkSub = await app.inject({ + method: 'GET', + url: `/api/global-folders/${subId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(checkSub.statusCode).toBe(404); + }); + + it('should return 404 for non-existent folder delete', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/global-folders/non-existent', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(404); + }); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e057106..7f4f76c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -583,8 +583,37 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack }, []); const handleCommandTrigger = useCallback((msg: string) => { + // Route group/ungroup actions from keyboard shortcuts + if (msg === 'group') { + const gm = groupManagerRef.current; + const ids = selectedElementIdsRef.current; + if (ids.length < 2) { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppieren: Mindestens 2 Elemente auswählen', type: 'info' }]); + return; + } + const group = gm.createGroup(ids); + setGroups(gm.getGroups()); + collab.setGroup(group); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe erstellt: ${group.name} (${ids.length} Elemente)`, type: 'info' }]); + return; + } + if (msg === 'ungroup') { + const gm = groupManagerRef.current; + const allGroups = gm.getGroups(); + if (allGroups.length === 0) { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Degruppieren: Keine Gruppe vorhanden', type: 'info' }]); + return; + } + const selIds = selectedElementIdsRef.current; + let targetGroup = allGroups.find(g => g.elementIds.some(id => selIds.includes(id))); + if (!targetGroup) targetGroup = allGroups[allGroups.length - 1]; + gm.ungroup(targetGroup.id); + setGroups(gm.getGroups()); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe aufgelöst: ${targetGroup.name}`, type: 'info' }]); + return; + } setCommandHistory((prev) => [...prev, { prefix: '·', text: msg, type: 'info' }]); - }, []); + }, [collab]); // Block management handlers const handleRenameBlock = useCallback((id: string, name: string) => { @@ -892,6 +921,38 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack return; } + // ─── Group/Ungroup actions ─── + if (action === 'group') { + const gm = groupManagerRef.current; + const ids = selectedElementIdsRef.current; + if (ids.length < 2) { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppieren: Mindestens 2 Elemente auswählen', type: 'info' }]); + return; + } + const group = gm.createGroup(ids); + const newGroups = gm.getGroups(); + setGroups(newGroups); + collab.setGroup(group); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe erstellt: ${group.name} (${ids.length} Elemente)`, type: 'info' }]); + return; + } + if (action === 'ungroup') { + const gm = groupManagerRef.current; + const allGroups = gm.getGroups(); + if (allGroups.length === 0) { + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Degruppieren: Keine Gruppe vorhanden', type: 'info' }]); + return; + } + // Find the group that contains any selected element + const selIds = selectedElementIdsRef.current; + let targetGroup = allGroups.find(g => g.elementIds.some(id => selIds.includes(id))); + if (!targetGroup) targetGroup = allGroups[allGroups.length - 1]; + gm.ungroup(targetGroup.id); + setGroups(gm.getGroups()); + setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe aufgelöst: ${targetGroup.name}`, type: 'info' }]); + return; + } + // ─── Insert actions (set active tool) ─── if (action === 'insert-line') { setActiveTool('line'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Linie-Werkzeug aktiv', type: 'info' }]); return; } if (action === 'insert-rect') { setActiveTool('rect'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Rechteck-Werkzeug aktiv', type: 'info' }]); return; } @@ -1429,6 +1490,8 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack onDeleteBlock={handleDeleteBlock} onSvgImport={handleSvgImport} onSaveGroupAsBlock={handleSaveGroupAsBlock} + onGroup={() => handleRibbonAction('group')} + onUngroup={() => handleRibbonAction('ungroup')} /> = ({ projectId, token, onNavigateBack selectedTemplate={selectedTemplate} bgConfig={bgConfig} remoteCursors={collab.cursors} + groups={groups} /> = ({ projectId, token, onNavigateBack onReorder={handleReorderLayer} onAddSubLayer={handleAddSubLayer} onUpdateElement={handleUpdateElement} + token={token} /> = []; + + setGroups(groups: Array<{ id: string; name: string; elementIds: string[]; parentGroupId: string | null }>): void { + this.groups = groups; + } + render(): void { const w = this.canvas.width / this.dpr; const h = this.canvas.height / this.dpr; @@ -168,6 +174,7 @@ export class RenderEngine { } this.drawSelectionBox(); + this.drawGroupBoxes(); if (this.options.showSnapPoints) this.drawSnapPoints(); if (this.options.showOrtho) this.drawOrtho(); this.ctx.restore(); @@ -825,6 +832,51 @@ export class RenderEngine { } } + private drawGroupBoxes(): void { + if (this.groups.length === 0) return; + const s = this.zoomPan.getScale(); + const ox = this.zoomPan.getTransform().e; + const oy = this.zoomPan.getTransform().f; + const visibleElements = this.spatialIndex.search(this.zoomPan.getViewport()); + const elementMap = new Map(visibleElements.map(e => [e.id, e])); + + for (const group of this.groups) { + const els = group.elementIds.map(id => elementMap.get(id)).filter((e): e is CADElement => e !== undefined); + if (els.length === 0) continue; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const el of els) { + const bb = this.getElementBBox(el); + minX = Math.min(minX, bb.minX); + minY = Math.min(minY, bb.minY); + maxX = Math.max(maxX, bb.maxX); + maxY = Math.max(maxY, bb.maxY); + } + if (minX === Infinity) continue; + + const sx1 = minX * s + ox; + const sy1 = minY * s + oy; + const sx2 = maxX * s + ox; + const sy2 = maxY * s + oy; + const pad = 4; + + this.ctx.save(); + this.ctx.strokeStyle = '#ff9800'; + this.ctx.lineWidth = 1.5; + this.ctx.setLineDash([6, 4]); + this.ctx.strokeRect(sx1 - pad, sy1 - pad, (sx2 - sx1) + pad * 2, (sy2 - sy1) + pad * 2); + this.ctx.setLineDash([]); + + // Group name label + this.ctx.font = '11px sans-serif'; + this.ctx.fillStyle = '#ff9800'; + this.ctx.textAlign = 'left'; + this.ctx.textBaseline = 'top'; + this.ctx.fillText(group.name, sx1 - pad + 2, sy1 - pad - 14); + this.ctx.restore(); + } + } + private drawSelectionBox(): void { if (!this.selection.boxStart || !this.selection.boxEnd) return; const s = this.zoomPan.getScale(); diff --git a/frontend/src/components/BlockLibraryTree.tsx b/frontend/src/components/BlockLibraryTree.tsx new file mode 100644 index 0000000..236edc9 --- /dev/null +++ b/frontend/src/components/BlockLibraryTree.tsx @@ -0,0 +1,463 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import type { GlobalBlockFolder, GlobalBlock } from '../services/api'; +import { + getGlobalFolders, + createGlobalFolder, + renameGlobalFolder, + deleteGlobalFolder, + getGlobalBlocks, + createGlobalBlock, + renameGlobalBlock, + deleteGlobalBlock, +} from '../services/api'; + +interface BlockLibraryTreeProps { + token: string; + onBlockDragStart: (blockId: string, blockData: string) => void; +} + +interface FolderNode { + folder: GlobalBlockFolder; + children: FolderNode[]; + blocks: GlobalBlock[]; + expanded: boolean; +} + +const BlockLibraryTree: React.FC = ({ token, onBlockDragStart }) => { + const [folders, setFolders] = useState([]); + const [blocks, setBlocks] = useState([]); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + type: 'folder' | 'block' | 'root'; + id: string | null; + parentId: string | null; + } | null>(null); + const [loading, setLoading] = useState(true); + const fileInputRef = useRef(null); + const [dragOverFolderId, setDragOverFolderId] = useState(null); + + const loadData = useCallback(async () => { + try { + const [allFolders, allBlocks] = await Promise.all([ + getGlobalFolders(token), + getGlobalBlocks(token), + ]); + setFolders(allFolders); + setBlocks(allBlocks); + } catch (err) { + // Silently handle — user will see empty tree + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // Build tree structure from flat lists + const buildTree = useCallback((): FolderNode[] => { + const folderMap = new Map(); + for (const f of folders) { + folderMap.set(f.id, { folder: f, children: [], blocks: [], expanded: expandedIds.has(f.id) }); + } + const roots: FolderNode[] = []; + for (const f of folders) { + const node = folderMap.get(f.id)!; + if (f.parent_id && folderMap.has(f.parent_id)) { + folderMap.get(f.parent_id)!.children.push(node); + } else { + roots.push(node); + } + } + // Attach blocks to folders + for (const b of blocks) { + if (b.folder_id && folderMap.has(b.folder_id)) { + folderMap.get(b.folder_id)!.blocks.push(b); + } + } + // Sort children and blocks by name + const sortRecursive = (nodes: FolderNode[]) => { + nodes.sort((a, b) => a.folder.name.localeCompare(b.folder.name)); + for (const n of nodes) { + n.blocks.sort((a, b) => a.name.localeCompare(b.name)); + sortRecursive(n.children); + } + }; + sortRecursive(roots); + return roots; + }, [folders, blocks, expandedIds]); + + const rootBlocks = blocks.filter(b => !b.folder_id).sort((a, b) => a.name.localeCompare(b.name)); + const tree = buildTree(); + + const toggleExpand = (folderId: string) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(folderId)) next.delete(folderId); + else next.add(folderId); + return next; + }); + }; + + const handleCreateFolder = async (parentId: string | null) => { + const name = window.prompt('Ordner-Name:', 'Neuer Ordner'); + if (!name) return; + try { + const created = await createGlobalFolder(token, name, parentId); + setFolders(prev => [...prev, created]); + if (parentId) { + setExpandedIds(prev => new Set(prev).add(parentId)); + } + } catch { + window.alert('Fehler beim Erstellen des Ordners'); + } + }; + + const handleRenameFolder = async (id: string, name: string) => { + if (!name.trim()) return; + try { + await renameGlobalFolder(token, id, name); + setFolders(prev => prev.map(f => f.id === id ? { ...f, name } : f)); + } catch { + window.alert('Fehler beim Umbenennen des Ordners'); + } + }; + + const handleDeleteFolder = async (id: string) => { + if (!window.confirm('Ordner und alle Inhalte löschen?')) return; + try { + await deleteGlobalFolder(token, id); + setFolders(prev => prev.filter(f => f.id !== id && f.parent_id !== id)); + // Blocks in deleted folder get folder_id = NULL (SET NULL in schema) + setBlocks(prev => prev.map(b => b.folder_id === id ? { ...b, folder_id: null } : b)); + } catch { + window.alert('Fehler beim Löschen des Ordners'); + } + }; + + const handleRenameBlock = async (id: string, name: string) => { + if (!name.trim()) return; + try { + await renameGlobalBlock(token, id, name); + setBlocks(prev => prev.map(b => b.id === id ? { ...b, name } : b)); + } catch { + window.alert('Fehler beim Umbenennen des Blocks'); + } + }; + + const handleDeleteBlock = async (id: string) => { + if (!window.confirm('Block löschen?')) return; + try { + await deleteGlobalBlock(token, id); + setBlocks(prev => prev.filter(b => b.id !== id)); + } catch { + window.alert('Fehler beim Löschen des Blocks'); + } + }; + + const handleBlockDragStart = (e: React.DragEvent, block: GlobalBlock) => { + e.dataTransfer.setData('text/global-block-id', block.id); + e.dataTransfer.setData('text/global-block-data', block.block_data); + e.dataTransfer.effectAllowed = 'copy'; + onBlockDragStart(block.id, block.block_data); + }; + + const handleFolderDragOver = (e: React.DragEvent, folderId: string) => { + e.preventDefault(); + e.stopPropagation(); + setDragOverFolderId(folderId); + }; + + const handleFolderDrop = async (e: React.DragEvent, folderId: string) => { + e.preventDefault(); + e.stopPropagation(); + setDragOverFolderId(null); + // Drop an SVG file into folder + const files = e.dataTransfer.files; + if (files && files.length > 0) { + const file = files[0]; + if (file.type === 'image/svg+xml' || file.name.endsWith('.svg')) { + const reader = new FileReader(); + reader.onload = async (ev) => { + const svgContent = ev.target?.result as string; + try { + const created = await createGlobalBlock(token, { + name: file.name.replace(/\.svg$/i, ''), + folder_id: folderId, + block_data: JSON.stringify({ type: 'svg', svg: svgContent }), + svg_data: svgContent, + }); + setBlocks(prev => [...prev, created]); + } catch { + window.alert('Fehler beim Speichern des SVG-Blocks'); + } + }; + reader.readAsText(file); + } + } + // Drop a canvas element (text/global-block-data means it's already a global block, skip) + const canvasElementData = e.dataTransfer.getData('text/canvas-element'); + if (canvasElementData) { + try { + const elementData = JSON.parse(canvasElementData); + const created = await createGlobalBlock(token, { + name: `Element ${Date.now()}`, + folder_id: folderId, + block_data: JSON.stringify(elementData), + }); + setBlocks(prev => [...prev, created]); + } catch { + window.alert('Fehler beim Speichern des Elements als Block'); + } + } + }; + + const handleSvgImport = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = async (ev) => { + const svgContent = ev.target?.result as string; + try { + const created = await createGlobalBlock(token, { + name: file.name.replace(/\.svg$/i, ''), + block_data: JSON.stringify({ type: 'svg', svg: svgContent }), + svg_data: svgContent, + }); + setBlocks(prev => [...prev, created]); + } catch { + window.alert('Fehler beim Importieren des SVG'); + } + }; + reader.readAsText(file); + e.target.value = ''; + }; + + const handleContextMenu = (e: React.MouseEvent, type: 'folder' | 'block' | 'root', id: string | null, parentId: string | null) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, type, id, parentId }); + }; + + const closeContextMenu = () => setContextMenu(null); + + useEffect(() => { + if (contextMenu) { + const handleClick = () => closeContextMenu(); + window.addEventListener('click', handleClick); + return () => window.removeEventListener('click', handleClick); + } + }, [contextMenu]); + + const startRename = (id: string, currentName: string) => { + setRenamingId(id); + setRenameValue(currentName); + }; + + const confirmRename = () => { + if (!renamingId) return; + const isFolder = folders.some(f => f.id === renamingId); + if (isFolder) { + handleRenameFolder(renamingId, renameValue); + } else { + handleRenameBlock(renamingId, renameValue); + } + setRenamingId(null); + setRenameValue(''); + }; + + const renderFolderNode = (node: FolderNode, depth: number): React.ReactNode => { + const isRenaming = renamingId === node.folder.id; + const isDragOver = dragOverFolderId === node.folder.id; + return ( +
+
toggleExpand(node.folder.id)} + onContextMenu={(e) => handleContextMenu(e, 'folder', node.folder.id, node.folder.parent_id)} + onDragOver={(e) => handleFolderDragOver(e, node.folder.id)} + onDragLeave={() => setDragOverFolderId(null)} + onDrop={(e) => handleFolderDrop(e, node.folder.id)} + > + {node.children.length > 0 || node.blocks.length > 0 ? (node.expanded ? '▾' : '▸') : '·'} + 📁 + {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={confirmRename} + onKeyDown={(e) => { + if (e.key === 'Enter') confirmRename(); + if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + { e.stopPropagation(); startRename(node.folder.id, node.folder.name); }} + > + {node.folder.name} + + )} + {node.blocks.length} +
+ {node.expanded && ( +
+ {node.children.map(child => renderFolderNode(child, depth + 1))} + {node.blocks.map(block => { + const isBlockRenaming = renamingId === block.id; + return ( +
handleBlockDragStart(e, block)} + onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)} + title={block.name} + > + 📦 + {isBlockRenaming ? ( + setRenameValue(e.target.value)} + onBlur={confirmRename} + onKeyDown={(e) => { + if (e.key === 'Enter') confirmRename(); + if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + { e.stopPropagation(); startRename(block.id, block.name); }} + > + {block.name} + + )} +
+ ); + })} +
+ )} +
+ ); + }; + + if (loading) { + return
Globale Bibliothek lädt…
; + } + + return ( +
handleContextMenu(e, 'root', null, null)}> +
+ 🌐 Globale Bibliothek + + +
+
+ {tree.map(node => renderFolderNode(node, 0))} + {rootBlocks.map(block => { + const isBlockRenaming = renamingId === block.id; + return ( +
handleBlockDragStart(e, block)} + onContextMenu={(e) => handleContextMenu(e, 'block', block.id, null)} + title={block.name} + > + 📦 + {isBlockRenaming ? ( + setRenameValue(e.target.value)} + onBlur={confirmRename} + onKeyDown={(e) => { + if (e.key === 'Enter') confirmRename(); + if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); } + }} + /> + ) : ( + { e.stopPropagation(); startRename(block.id, block.name); }} + > + {block.name} + + )} +
+ ); + })} + {folders.length === 0 && blocks.length === 0 && ( +
Globale Bibliothek ist leer — Rechtsklick für Ordner
+ )} +
+ + {contextMenu && ( +
e.stopPropagation()} + > + {contextMenu.type === 'root' && ( + <> + + + )} + {contextMenu.type === 'folder' && contextMenu.id && ( + <> + + + + + )} + {contextMenu.type === 'block' && contextMenu.id && ( + <> + + + + )} +
+ )} +
+ ); +}; + +export default BlockLibraryTree; diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index 67849d1..debabc6 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -10,12 +10,14 @@ import { SnapEngine } from '../canvas/SnapEngine'; import { SelectionEngine } from '../canvas/SelectionEngine'; import { SpatialIndex } from '../canvas/SpatialIndex'; import { LayerManager } from '../canvas/LayerManager'; +import { GroupManager } from '../tools/modification/GroupTool'; const CanvasArea: React.FC = ({ cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled, polarEnabled, activeTool, elements, layers, activeLayerId, onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit, onTextEdit, onCommandTrigger, blocks, onBlockDrop, onSelectionChange, selectedTemplate, bgConfig, remoteCursors, zoomCommand, + groups, }) => { const canvasRef = useRef(null); const zoomPanRef = useRef(null); @@ -149,6 +151,24 @@ const CanvasArea: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [blocks]); + // Sync groups to RenderEngine and InteractionEngine + useEffect(() => { + const renderEngine = renderEngineRef.current; + const interaction = interactionRef.current; + if (!renderEngine) return; + renderEngine.setGroups(groups ?? []); + renderEngine.render(); + // InteractionEngine uses GroupManager from App — we pass it via a simple adapter + if (interaction && groups) { + const gm = new GroupManager(); + gm.fromJSON(groups); + interaction.setGroupManager(gm); + } else if (interaction) { + interaction.setGroupManager(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groups]); + // Sync active layer to LayerManager useEffect(() => { const layerManager = layerManagerRef.current; @@ -294,15 +314,59 @@ const CanvasArea: React.FC = ({ onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }} onDrop={(e) => { e.preventDefault(); - const blockId = e.dataTransfer.getData('text/block-id'); - if (!blockId || !onBlockDrop) return; const rect = e.currentTarget.getBoundingClientRect(); const sx = e.clientX - rect.left; const sy = e.clientY - rect.top; const zoomPan = zoomPanRef.current; if (!zoomPan) return; const world = zoomPan.screenToWorld(sx, sy); - onBlockDrop(blockId, world.x, world.y); + // Project block drop + const blockId = e.dataTransfer.getData('text/block-id'); + if (blockId && onBlockDrop) { + onBlockDrop(blockId, world.x, world.y); + return; + } + // Global block drop — parse block_data and create element(s) + const globalBlockData = e.dataTransfer.getData('text/global-block-data'); + if (globalBlockData) { + try { + const parsed = JSON.parse(globalBlockData); + if (parsed.type === 'svg' && parsed.svg) { + // SVG block — create a block_instance element + const svgEl: CADElement = { + id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + type: 'block_instance', + layerId: 'layer-0', + x: world.x, + y: world.y, + width: 100, + height: 100, + properties: { blockId: `svg_${Date.now()}`, svgData: parsed.svg, scale: 1 }, + }; + onElementCreated(svgEl); + } else if (Array.isArray(parsed)) { + // Array of elements — create them with offset + parsed.forEach((el: CADElement, i: number) => { + onElementCreated({ + ...el, + id: `el_${Date.now()}_${i}`, + x: (el.x ?? 0) + world.x, + y: (el.y ?? 0) + world.y, + }); + }); + } else if (parsed.type) { + // Single element + onElementCreated({ + ...parsed, + id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + x: world.x, + y: world.y, + }); + } + } catch { + // Invalid block data — ignore + } + } }} /> diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx index c222242..4e3ce06 100644 --- a/frontend/src/components/LeftSidebar.tsx +++ b/frontend/src/components/LeftSidebar.tsx @@ -56,7 +56,7 @@ const sections: Array<{ label: string; tools: ToolDef[] }> = [ { label: 'Bestuhlung', tools: sectionBestuhlung }, ]; -const LeftSidebar: React.FC = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect, onCollapse, className, collapsed, onToggleCollapse }) => { +const LeftSidebar: React.FC = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect, onCollapse, className, collapsed, onToggleCollapse, onGroup, onUngroup }) => { return (