feat: global block library tree + grouping tool — 19 files, 588 tests green
This commit is contained in:
@@ -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<void>;
|
||||
close(): void;
|
||||
@@ -165,4 +182,18 @@ export interface DatabaseInterface {
|
||||
getProjectShare(id: string): DBProjectShare | null;
|
||||
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare;
|
||||
deleteProjectShare(id: string): boolean;
|
||||
|
||||
// Global Block Folders
|
||||
listGlobalFolders(parentId?: string | null): DBGlobalBlockFolder[];
|
||||
getGlobalFolder(id: string): DBGlobalBlockFolder | null;
|
||||
createGlobalFolder(data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder;
|
||||
updateGlobalFolder(id: string, data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder | null;
|
||||
deleteGlobalFolder(id: string): boolean;
|
||||
|
||||
// Global Blocks
|
||||
listGlobalBlocks(folderId?: string | null): DBGlobalBlock[];
|
||||
getGlobalBlock(id: string): DBGlobalBlock | null;
|
||||
createGlobalBlock(data: Partial<DBGlobalBlock>): DBGlobalBlock;
|
||||
updateGlobalBlock(id: string, data: Partial<DBGlobalBlock>): DBGlobalBlock | null;
|
||||
deleteGlobalBlock(id: string): boolean;
|
||||
}
|
||||
|
||||
@@ -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>): 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>): 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>): 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>): 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '<svg><rect width="50" height="50"/></svg>',
|
||||
},
|
||||
});
|
||||
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('<svg>');
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+66
-1
@@ -583,8 +583,37 @@ const CADEditor: React.FC<CADEditorProps> = ({ 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<CADEditorProps> = ({ 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<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onSvgImport={handleSvgImport}
|
||||
onSaveGroupAsBlock={handleSaveGroupAsBlock}
|
||||
onGroup={() => handleRibbonAction('group')}
|
||||
onUngroup={() => handleRibbonAction('ungroup')}
|
||||
/>
|
||||
<CanvasArea
|
||||
cursorPos={cursorPos}
|
||||
@@ -1461,6 +1524,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
selectedTemplate={selectedTemplate}
|
||||
bgConfig={bgConfig}
|
||||
remoteCursors={collab.cursors}
|
||||
groups={groups}
|
||||
/>
|
||||
<RightSidebar
|
||||
activePanel={activeRightPanel}
|
||||
@@ -1499,6 +1563,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
onReorder={handleReorderLayer}
|
||||
onAddSubLayer={handleAddSubLayer}
|
||||
onUpdateElement={handleUpdateElement}
|
||||
token={token}
|
||||
/>
|
||||
</div>
|
||||
<CommandLine
|
||||
|
||||
@@ -132,6 +132,12 @@ export class RenderEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private groups: Array<{ id: string; name: string; elementIds: string[]; parentGroupId: string | null }> = [];
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<BlockLibraryTreeProps> = ({ token, onBlockDragStart }) => {
|
||||
const [folders, setFolders] = useState<GlobalBlockFolder[]>([]);
|
||||
const [blocks, setBlocks] = useState<GlobalBlock[]>([]);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [renamingId, setRenamingId] = useState<string | null>(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<HTMLInputElement>(null);
|
||||
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(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<string, FolderNode>();
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div key={node.folder.id}>
|
||||
<div
|
||||
className={`tree-item tree-item-folder${isDragOver ? ' drag-over' : ''}`}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => 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)}
|
||||
>
|
||||
<span className="tree-toggle">{node.children.length > 0 || node.blocks.length > 0 ? (node.expanded ? '▾' : '▸') : '·'}</span>
|
||||
<span className="tree-icon">📁</span>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
type="text"
|
||||
className="tree-rename-input"
|
||||
value={renameValue}
|
||||
autoFocus
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={confirmRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') confirmRename();
|
||||
if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); }
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="tree-label"
|
||||
onDoubleClick={(e) => { e.stopPropagation(); startRename(node.folder.id, node.folder.name); }}
|
||||
>
|
||||
{node.folder.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="tree-count">{node.blocks.length}</span>
|
||||
</div>
|
||||
{node.expanded && (
|
||||
<div className="tree-children">
|
||||
{node.children.map(child => renderFolderNode(child, depth + 1))}
|
||||
{node.blocks.map(block => {
|
||||
const isBlockRenaming = renamingId === block.id;
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
className="tree-item tree-item-leaf draggable"
|
||||
style={{ paddingLeft: `${(depth + 1) * 16 + 8}px` }}
|
||||
draggable
|
||||
onDragStart={(e) => handleBlockDragStart(e, block)}
|
||||
onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)}
|
||||
title={block.name}
|
||||
>
|
||||
<span className="tree-icon">📦</span>
|
||||
{isBlockRenaming ? (
|
||||
<input
|
||||
type="text"
|
||||
className="tree-rename-input"
|
||||
value={renameValue}
|
||||
autoFocus
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={confirmRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') confirmRename();
|
||||
if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); }
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="tree-label"
|
||||
onDoubleClick={(e) => { e.stopPropagation(); startRename(block.id, block.name); }}
|
||||
>
|
||||
{block.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="global-lib-loading">Globale Bibliothek lädt…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="global-lib-tree" onContextMenu={(e) => handleContextMenu(e, 'root', null, null)}>
|
||||
<div className="global-lib-header">
|
||||
<span className="global-lib-title">🌐 Globale Bibliothek</span>
|
||||
<button
|
||||
className="global-lib-add-btn"
|
||||
title="SVG in globale Bibliothek importieren"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".svg,image/svg+xml" style={{ display: 'none' }} onChange={handleSvgImport} />
|
||||
</div>
|
||||
<div className="tree tree-global" role="tree" aria-label="Globale Block-Bibliothek">
|
||||
{tree.map(node => renderFolderNode(node, 0))}
|
||||
{rootBlocks.map(block => {
|
||||
const isBlockRenaming = renamingId === block.id;
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
className="tree-item tree-item-leaf draggable"
|
||||
draggable
|
||||
onDragStart={(e) => handleBlockDragStart(e, block)}
|
||||
onContextMenu={(e) => handleContextMenu(e, 'block', block.id, null)}
|
||||
title={block.name}
|
||||
>
|
||||
<span className="tree-icon">📦</span>
|
||||
{isBlockRenaming ? (
|
||||
<input
|
||||
type="text"
|
||||
className="tree-rename-input"
|
||||
value={renameValue}
|
||||
autoFocus
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={confirmRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') confirmRename();
|
||||
if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="tree-label"
|
||||
onDoubleClick={(e) => { e.stopPropagation(); startRename(block.id, block.name); }}
|
||||
>
|
||||
{block.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{folders.length === 0 && blocks.length === 0 && (
|
||||
<div className="global-lib-empty">Globale Bibliothek ist leer — Rechtsklick für Ordner</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="context-menu"
|
||||
style={{ position: 'fixed', left: contextMenu.x, top: contextMenu.y, zIndex: 10000 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{contextMenu.type === 'root' && (
|
||||
<>
|
||||
<button className="context-menu-item" onClick={() => { handleCreateFolder(null); closeContextMenu(); }}>
|
||||
📁 Neuer Ordner
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{contextMenu.type === 'folder' && contextMenu.id && (
|
||||
<>
|
||||
<button className="context-menu-item" onClick={() => { handleCreateFolder(contextMenu.id); closeContextMenu(); }}>
|
||||
📁 Neuer Unterordner
|
||||
</button>
|
||||
<button className="context-menu-item" onClick={() => { if (contextMenu.id) startRename(contextMenu.id, folders.find(f => f.id === contextMenu.id)?.name ?? ''); closeContextMenu(); }}>
|
||||
✏️ Umbenennen
|
||||
</button>
|
||||
<button className="context-menu-item danger" onClick={() => { if (contextMenu.id) handleDeleteFolder(contextMenu.id); closeContextMenu(); }}>
|
||||
🗑️ Löschen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{contextMenu.type === 'block' && contextMenu.id && (
|
||||
<>
|
||||
<button className="context-menu-item" onClick={() => { if (contextMenu.id) startRename(contextMenu.id, blocks.find(b => b.id === contextMenu.id)?.name ?? ''); closeContextMenu(); }}>
|
||||
✏️ Umbenennen
|
||||
</button>
|
||||
<button className="context-menu-item danger" onClick={() => { if (contextMenu.id) handleDeleteBlock(contextMenu.id); closeContextMenu(); }}>
|
||||
🗑️ Löschen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockLibraryTree;
|
||||
@@ -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<CanvasAreaProps> = ({
|
||||
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
|
||||
polarEnabled,
|
||||
activeTool, elements, layers, activeLayerId, onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged,
|
||||
onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit, onTextEdit, onCommandTrigger, blocks, onBlockDrop, onSelectionChange, selectedTemplate, bgConfig, remoteCursors, zoomCommand,
|
||||
groups,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const zoomPanRef = useRef<ZoomPanController | null>(null);
|
||||
@@ -149,6 +151,24 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
|
||||
// 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<CanvasAreaProps> = ({
|
||||
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
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ const sections: Array<{ label: string; tools: ToolDef[] }> = [
|
||||
{ label: 'Bestuhlung', tools: sectionBestuhlung },
|
||||
];
|
||||
|
||||
const LeftSidebar: React.FC<LeftSidebarProps> = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect, onCollapse, className, collapsed, onToggleCollapse }) => {
|
||||
const LeftSidebar: React.FC<LeftSidebarProps> = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect, onCollapse, className, collapsed, onToggleCollapse, onGroup, onUngroup }) => {
|
||||
return (
|
||||
<aside className={`leftbar${className ? ' ' + className : ''}${collapsed ? ' leftbar-collapsed' : ''}`} aria-label="Werkzeugpalette">
|
||||
<div className="leftbar-header">
|
||||
@@ -87,6 +87,36 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ activeTool, onToolChange, sel
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(onGroup || onUngroup) && (
|
||||
<div className="tool-section" key="grouping">
|
||||
<div className="tool-section-label">Gruppierung</div>
|
||||
<div className="tool-grid">
|
||||
{onGroup && (
|
||||
<button
|
||||
className="tool-btn"
|
||||
title="Gruppieren (G)"
|
||||
onClick={onGroup}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><path d="M10 10l4 4"/></svg>
|
||||
<span className="tool-btn-label">Gruppieren</span>
|
||||
<span className="tool-btn-kbd">G</span>
|
||||
</button>
|
||||
)}
|
||||
{onUngroup && (
|
||||
<button
|
||||
className="tool-btn"
|
||||
title="Degruppieren (Strg+G)"
|
||||
onClick={onUngroup}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1" strokeDasharray="2 2"/><rect x="14" y="14" width="7" height="7" rx="1" strokeDasharray="2 2"/><path d="M10 10l4 4" strokeDasharray="2 2"/></svg>
|
||||
<span className="tool-btn-label">Degrupp.</span>
|
||||
<span className="tool-btn-kbd">Strg+G</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTool === 'seating-template' && onTemplateSelect && (
|
||||
<div className="tool-section" key="templates">
|
||||
<div className="tool-section-label">Vorlagen</div>
|
||||
|
||||
@@ -80,6 +80,14 @@ const RibbonBar: React.FC<RibbonBarProps> = ({ activeTab, onTabChange, onAction
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>
|
||||
<span>Einfügen</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Gruppieren (G)" onClick={() => onAction('group')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><path d="M10 10l4 4"/></svg>
|
||||
<span>Gruppieren</span>
|
||||
</button>
|
||||
<button className="ribbon-btn" title="Degruppieren (Strg+G)" onClick={() => onAction('ungroup')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1" strokeDasharray="2 2"/><rect x="14" y="14" width="7" height="7" rx="1" strokeDasharray="2 2"/><path d="M10 10l4 4" strokeDasharray="2 2"/></svg>
|
||||
<span>Degruppieren</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ribbon-group-label">Bearbeiten</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RightSidebarProps, RightPanel } from '../types/ui.types';
|
||||
import PropertiesPanel from './PropertiesPanel';
|
||||
import LayerPanel from './LayerPanel';
|
||||
import BlockLibrary from './BlockLibrary';
|
||||
import BlockLibraryTree from './BlockLibraryTree';
|
||||
import KICopilot from './KICopilot';
|
||||
|
||||
const tabs: Array<{ id: RightPanel; label: string; svg: React.ReactNode; badge?: number }> = [
|
||||
@@ -26,6 +27,7 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
|
||||
onCollapse,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
token,
|
||||
}) => {
|
||||
return (
|
||||
<aside className={`rightbar${className ? ' ' + className : ''}${collapsed ? ' rightbar-collapsed' : ''}`} aria-label="Eigenschaften-Panels">
|
||||
@@ -80,7 +82,19 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
|
||||
{activePanel === 'layer' && <LayerPanel layers={layers} elements={elements} onElementsDeleted={onElementsDeleted} onToggleElementVisible={onToggleElementVisible} activeLayerId={activeLayerId} onSelectLayer={onSelectLayer ?? (() => {})} onAddLayer={onAddLayer ?? (() => {})} onToggleLayer={onToggleLayer ?? (() => {})} onDeleteLayer={onDeleteLayer} onRenameLayer={onRenameLayer} onDuplicateLayer={onDuplicateLayer} onToggleLock={onToggleLock} onReorder={onReorder} onAddSubLayer={onAddSubLayer} />}
|
||||
</div>
|
||||
<div className={`rightbar-panel${activePanel === 'library' ? ' active' : ''}`} data-panel-content="library">
|
||||
{activePanel === 'library' && <BlockLibrary blocks={blocks} category="Alle" onCategoryChange={onBlockCategoryChange ?? (() => {})} onSearch={onBlockSearch ?? (() => {})} onDragBlock={onDragBlock ?? (() => {})} onRenameBlock={onRenameBlock} onDuplicateBlock={onDuplicateBlock} onDeleteBlock={onDeleteBlock} onSvgImport={onSvgImport} onSaveGroupAsBlock={onSaveGroupAsBlock} />}
|
||||
{activePanel === 'library' && (
|
||||
<>
|
||||
<BlockLibrary blocks={blocks} category="Alle" onCategoryChange={onBlockCategoryChange ?? (() => {})} onSearch={onBlockSearch ?? (() => {})} onDragBlock={onDragBlock ?? (() => {})} onRenameBlock={onRenameBlock} onDuplicateBlock={onDuplicateBlock} onDeleteBlock={onDeleteBlock} onSvgImport={onSvgImport} onSaveGroupAsBlock={onSaveGroupAsBlock} />
|
||||
{token && (
|
||||
<BlockLibraryTree
|
||||
token={token}
|
||||
onBlockDragStart={(_blockId, _blockData) => {
|
||||
// Global blocks use a different drag type — handled in CanvasArea drop
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={`rightbar-panel${activePanel === 'ki' ? ' active' : ''}`} data-panel-content="ki">
|
||||
{activePanel === 'ki' && <KICopilot messages={kiMessages ?? []} suggestions={kiSuggestions ?? []} onSend={onKISend ?? (() => {})} onSuggestionClick={onKISuggestionClick ?? (() => {})} loading={kiLoading} />}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
import { moveElement, rotateElement, scaleElement, mirrorElement, offsetElement, trimElement, extendElement, filletElements, distance, angleBetween } from '../tools/modification/geometry';
|
||||
import { SeatingService, SEATING_TEMPLATES } from '../services/seatingService';
|
||||
import { DimensionService } from '../services/dimensionService';
|
||||
@@ -42,6 +43,7 @@ export class InteractionEngine {
|
||||
private config: InteractionConfig;
|
||||
private selectedTemplate: string | null = null;
|
||||
private allElements: CADElement[] = [];
|
||||
private groupManager: GroupManager | null = null;
|
||||
private isMouseDown = false;
|
||||
private isRightDown = false;
|
||||
private lastMouseScreen = { x: 0, y: 0 };
|
||||
@@ -104,6 +106,10 @@ export class InteractionEngine {
|
||||
this.snapEngine.setElements(elements);
|
||||
}
|
||||
|
||||
setGroupManager(gm: GroupManager | null): void {
|
||||
this.groupManager = gm;
|
||||
}
|
||||
|
||||
setSnapEnabled(enabled: boolean): void {
|
||||
this.state.snapEnabled = enabled;
|
||||
}
|
||||
@@ -436,6 +442,22 @@ export class InteractionEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
// G = Group selected elements
|
||||
if (e.key === 'g' && !e.ctrlKey && !e.metaKey && this.state.activeTool === 'select') {
|
||||
const ids = Array.from(this.selectionEngine.getSelectedIds());
|
||||
if (ids.length >= 2) {
|
||||
this.onCommandTrigger?.('group');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+G = Ungroup
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'g' && this.state.activeTool === 'select') {
|
||||
e.preventDefault();
|
||||
this.onCommandTrigger?.('ungroup');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+A = select all
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === this.config.selectAllKey) {
|
||||
e.preventDefault();
|
||||
@@ -491,6 +513,23 @@ export class InteractionEngine {
|
||||
// Start potential box select
|
||||
this.selectionEngine.startBoxSelect(world.x, world.y);
|
||||
} else {
|
||||
// Group-aware click selection: if clicked element is in a group, select all group members
|
||||
const hit = this.renderEngine.hitTest(world.x, world.y, 5);
|
||||
if (hit && this.groupManager) {
|
||||
const groupElements = this.groupManager.getGroupElements(hit.id);
|
||||
if (groupElements.length > 1) {
|
||||
if (subtractive) {
|
||||
// Remove all group elements from selection
|
||||
const currentIds = Array.from(this.selectionEngine.getSelectedIds());
|
||||
const filtered = currentIds.filter(id => !groupElements.includes(id));
|
||||
this.selectionEngine.selectByIds(filtered, false);
|
||||
} else {
|
||||
this.selectionEngine.selectByIds(groupElements, !additive ? false : true);
|
||||
}
|
||||
this.emitSelectionChange();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.selectionEngine.clickSelect(world.x, world.y, this.allElements);
|
||||
this.emitSelectionChange();
|
||||
}
|
||||
@@ -755,7 +794,22 @@ export class InteractionEngine {
|
||||
if (tool === 'move') {
|
||||
const dx = pt.x - base.x;
|
||||
const dy = pt.y - base.y;
|
||||
const modified = this.modifySelected.map(el => moveElement(el, dx, dy));
|
||||
// Group-aware: expand modifySelected to include group members
|
||||
let toMove = [...this.modifySelected];
|
||||
if (this.groupManager) {
|
||||
const additionalGroupEls: CADElement[] = [];
|
||||
for (const el of this.modifySelected) {
|
||||
const groupEls = this.groupManager.getGroupElements(el.id);
|
||||
for (const gid of groupEls) {
|
||||
if (!toMove.some(e => e.id === gid)) {
|
||||
const found = this.allElements.find(e => e.id === gid);
|
||||
if (found) additionalGroupEls.push(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
toMove = [...toMove, ...additionalGroupEls];
|
||||
}
|
||||
const modified = toMove.map(el => moveElement(el, dx, dy));
|
||||
this.onElementsModified?.(modified);
|
||||
} else if (tool === 'copy') {
|
||||
const dx = pt.x - base.x;
|
||||
|
||||
@@ -510,6 +510,102 @@ export async function deleteProjectShare(token: string, shareId: string): Promis
|
||||
if (!res.ok) throw new Error('Failed to delete share');
|
||||
}
|
||||
|
||||
// ─── Global Block Folders ───────────────────────────────────
|
||||
export interface GlobalBlockFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function getGlobalFolders(token: string, parentId?: string | null): Promise<GlobalBlockFolder[]> {
|
||||
const url = parentId !== undefined
|
||||
? `${API_BASE}/api/global-folders?parentId=${parentId === null ? 'null' : parentId}`
|
||||
: `${API_BASE}/api/global-folders`;
|
||||
const res = await fetch(url, { headers: authHeaders(token) });
|
||||
if (!res.ok) throw new Error('Failed to load global folders');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createGlobalFolder(token: string, name: string, parentId?: string | null): Promise<GlobalBlockFolder> {
|
||||
const res = await fetch(`${API_BASE}/api/global-folders`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ name, parent_id: parentId ?? null }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create global folder');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function renameGlobalFolder(token: string, id: string, name: string): Promise<GlobalBlockFolder> {
|
||||
const res = await fetch(`${API_BASE}/api/global-folders/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to rename global folder');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteGlobalFolder(token: string, id: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/api/global-folders/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Global Blocks ────────────────────────────────────────
|
||||
export interface GlobalBlock {
|
||||
id: string;
|
||||
folder_id: string | null;
|
||||
name: string;
|
||||
block_data: string;
|
||||
svg_data: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function getGlobalBlocks(token: string, folderId?: string | null): Promise<GlobalBlock[]> {
|
||||
const url = folderId !== undefined
|
||||
? `${API_BASE}/api/global-blocks?folderId=${folderId === null ? 'null' : folderId}`
|
||||
: `${API_BASE}/api/global-blocks`;
|
||||
const res = await fetch(url, { headers: authHeaders(token) });
|
||||
if (!res.ok) throw new Error('Failed to load global blocks');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createGlobalBlock(token: string, data: {
|
||||
name: string;
|
||||
folder_id?: string | null;
|
||||
block_data?: string;
|
||||
svg_data?: string;
|
||||
}): Promise<GlobalBlock> {
|
||||
const res = await fetch(`${API_BASE}/api/global-blocks`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create global block');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function renameGlobalBlock(token: string, id: string, name: string): Promise<GlobalBlock> {
|
||||
const res = await fetch(`${API_BASE}/api/global-blocks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to rename global block');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteGlobalBlock(token: string, id: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/api/global-blocks/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
}
|
||||
|
||||
export { API_BASE };
|
||||
|
||||
// ─── AI Copilot ─────────────────────────────────────────
|
||||
|
||||
@@ -2270,3 +2270,145 @@ body.drawer-open { overflow: hidden; }
|
||||
.export-format-cancel:hover {
|
||||
background: var(--color-surface-2, #2a2a3e);
|
||||
}
|
||||
|
||||
/* ─── Global Block Library Tree ────────────────────────── */
|
||||
.global-lib-tree {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.global-lib-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.global-lib-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.global-lib-add-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-faint);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.global-lib-add-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.global-lib-loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.global-lib-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--color-text-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tree-global .tree-item {
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tree-global .tree-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.tree-global .tree-item.drag-over {
|
||||
background: var(--color-accent-dim);
|
||||
outline: 1px dashed var(--color-accent);
|
||||
}
|
||||
|
||||
.tree-global .tree-item-leaf {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.tree-global .tree-item-leaf:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tree-rename-input {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-accent);
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ─── Context Menu ─────────────────────────────────────── */
|
||||
.context-menu {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: 4px;
|
||||
min-width: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.context-menu-item.danger:hover {
|
||||
background: rgba(220, 53, 69, 0.15);
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
/* ─── Group Section in LeftSidebar ─────────────────────── */
|
||||
.tool-section .tool-btn[title*="Gruppieren"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-section .tool-btn[title*="Gruppieren"]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export class GroupManager {
|
||||
/**
|
||||
* Move all elements in a group by dx, dy.
|
||||
* Returns the list of element IDs that need to be modified.
|
||||
* Also returns the delta values so the caller can apply the actual movement.
|
||||
*/
|
||||
moveGroup(groupId: string, _dx: number, _dy: number): string[] {
|
||||
const group = this.groups.get(groupId);
|
||||
@@ -88,6 +89,18 @@ export class GroupManager {
|
||||
return [...group.elementIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all element IDs belonging to the same group as the given element ID.
|
||||
* Returns an array including the element itself if it's in a group.
|
||||
*/
|
||||
getGroupElements(elementId: string): string[] {
|
||||
const groupId = this.getGroupForElement(elementId);
|
||||
if (!groupId) return [elementId];
|
||||
const group = this.groups.get(groupId);
|
||||
if (!group) return [elementId];
|
||||
return [...group.elementIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set parent group (nesting).
|
||||
*/
|
||||
|
||||
@@ -99,6 +99,8 @@ export interface LeftSidebarProps {
|
||||
onDeleteBlock?: (id: string) => void;
|
||||
onSvgImport?: (svg: string, name: string, category: string) => void;
|
||||
onSaveGroupAsBlock?: (name: string) => void;
|
||||
onGroup?: () => void;
|
||||
onUngroup?: () => void;
|
||||
}
|
||||
|
||||
export interface CanvasAreaProps {
|
||||
@@ -133,6 +135,7 @@ export interface CanvasAreaProps {
|
||||
selectedTemplate?: string | null;
|
||||
bgConfig?: BackgroundConfig | null;
|
||||
remoteCursors?: UserCursor[];
|
||||
groups?: Array<{ id: string; name: string; elementIds: string[]; parentGroupId: string | null }>;
|
||||
}
|
||||
|
||||
export interface RightSidebarProps {
|
||||
@@ -171,6 +174,7 @@ export interface RightSidebarProps {
|
||||
onKISuggestionClick?: (suggestion: KISuggestion) => void;
|
||||
kiLoading?: boolean;
|
||||
onUpdateElement?: (el: CADElement) => void;
|
||||
token?: string;
|
||||
className?: string;
|
||||
onCollapse?: () => void;
|
||||
collapsed?: boolean;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Global Blocks API Service Tests
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
// Import after mock is set up
|
||||
import {
|
||||
getGlobalFolders,
|
||||
createGlobalFolder,
|
||||
renameGlobalFolder,
|
||||
deleteGlobalFolder,
|
||||
getGlobalBlocks,
|
||||
createGlobalBlock,
|
||||
renameGlobalBlock,
|
||||
deleteGlobalBlock,
|
||||
} from '../src/services/api';
|
||||
|
||||
const TOKEN = 'test-token-123';
|
||||
|
||||
function mockResponse(data: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: () => Promise.resolve(data),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe('Global Blocks API Service', () => {
|
||||
describe('getGlobalFolders', () => {
|
||||
it('should fetch all folders', async () => {
|
||||
const folders = [{ id: 'f1', name: 'Folder 1', parent_id: null, created_at: '2026-01-01' }];
|
||||
mockFetch.mockResolvedValue(mockResponse(folders));
|
||||
const result = await getGlobalFolders(TOKEN);
|
||||
expect(result).toEqual(folders);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/global-folders'),
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: `Bearer ${TOKEN}` }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch root folders when parentId=null', async () => {
|
||||
const folders = [{ id: 'f1', name: 'Root', parent_id: null, created_at: '2026-01-01' }];
|
||||
mockFetch.mockResolvedValue(mockResponse(folders));
|
||||
const result = await getGlobalFolders(TOKEN, null);
|
||||
expect(result).toEqual(folders);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('parentId=null'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch sub-folders for a parent', async () => {
|
||||
const folders = [{ id: 'f2', name: 'Sub', parent_id: 'f1', created_at: '2026-01-01' }];
|
||||
mockFetch.mockResolvedValue(mockResponse(folders));
|
||||
const result = await getGlobalFolders(TOKEN, 'f1');
|
||||
expect(result).toEqual(folders);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('parentId=f1'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on fetch failure', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse({ error: 'fail' }, false, 500));
|
||||
await expect(getGlobalFolders(TOKEN)).rejects.toThrow('Failed to load global folders');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGlobalFolder', () => {
|
||||
it('should create a folder', async () => {
|
||||
const folder = { id: 'f1', name: 'New Folder', parent_id: null, created_at: '2026-01-01' };
|
||||
mockFetch.mockResolvedValue(mockResponse(folder, true, 201));
|
||||
const result = await createGlobalFolder(TOKEN, 'New Folder');
|
||||
expect(result).toEqual(folder);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/global-folders'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: 'New Folder', parent_id: null }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a sub-folder with parentId', async () => {
|
||||
const folder = { id: 'f2', name: 'Sub', parent_id: 'f1', created_at: '2026-01-01' };
|
||||
mockFetch.mockResolvedValue(mockResponse(folder, true, 201));
|
||||
const result = await createGlobalFolder(TOKEN, 'Sub', 'f1');
|
||||
expect(result).toEqual(folder);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ name: 'Sub', parent_id: 'f1' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameGlobalFolder', () => {
|
||||
it('should rename a folder', async () => {
|
||||
const folder = { id: 'f1', name: 'Renamed', parent_id: null, created_at: '2026-01-01' };
|
||||
mockFetch.mockResolvedValue(mockResponse(folder));
|
||||
const result = await renameGlobalFolder(TOKEN, 'f1', 'Renamed');
|
||||
expect(result).toEqual(folder);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/global-folders/f1'),
|
||||
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ name: 'Renamed' }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteGlobalFolder', () => {
|
||||
it('should delete a folder', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse(null, true, 204));
|
||||
await deleteGlobalFolder(TOKEN, 'f1');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/global-folders/f1'),
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalBlocks', () => {
|
||||
it('should fetch all blocks', async () => {
|
||||
const blocks = [{ id: 'b1', folder_id: null, name: 'Block 1', block_data: '{}', svg_data: null, created_at: '2026-01-01', updated_at: '2026-01-01' }];
|
||||
mockFetch.mockResolvedValue(mockResponse(blocks));
|
||||
const result = await getGlobalBlocks(TOKEN);
|
||||
expect(result).toEqual(blocks);
|
||||
});
|
||||
|
||||
it('should fetch blocks for a folder', async () => {
|
||||
const blocks = [{ id: 'b1', folder_id: 'f1', name: 'Block 1', block_data: '{}', svg_data: null, created_at: '2026-01-01', updated_at: '2026-01-01' }];
|
||||
mockFetch.mockResolvedValue(mockResponse(blocks));
|
||||
const result = await getGlobalBlocks(TOKEN, 'f1');
|
||||
expect(result).toEqual(blocks);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('folderId=f1'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch root-level blocks with folderId=null', async () => {
|
||||
const blocks = [{ id: 'b1', folder_id: null, name: 'Root Block', block_data: '{}', svg_data: null, created_at: '2026-01-01', updated_at: '2026-01-01' }];
|
||||
mockFetch.mockResolvedValue(mockResponse(blocks));
|
||||
const result = await getGlobalBlocks(TOKEN, null);
|
||||
expect(result).toEqual(blocks);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('folderId=null'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGlobalBlock', () => {
|
||||
it('should create a block with full data', async () => {
|
||||
const block = { id: 'b1', folder_id: 'f1', name: 'Chair', block_data: '{"type":"chair"}', svg_data: '<svg/>', created_at: '2026-01-01', updated_at: '2026-01-01' };
|
||||
mockFetch.mockResolvedValue(mockResponse(block, true, 201));
|
||||
const result = await createGlobalBlock(TOKEN, {
|
||||
name: 'Chair',
|
||||
folder_id: 'f1',
|
||||
block_data: '{"type":"chair"}',
|
||||
svg_data: '<svg/>',
|
||||
});
|
||||
expect(result).toEqual(block);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: 'Chair',
|
||||
folder_id: 'f1',
|
||||
block_data: '{"type":"chair"}',
|
||||
svg_data: '<svg/>',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameGlobalBlock', () => {
|
||||
it('should rename a block', async () => {
|
||||
const block = { id: 'b1', folder_id: null, name: 'Renamed', block_data: '{}', svg_data: null, created_at: '2026-01-01', updated_at: '2026-01-01' };
|
||||
mockFetch.mockResolvedValue(mockResponse(block));
|
||||
const result = await renameGlobalBlock(TOKEN, 'b1', 'Renamed');
|
||||
expect(result).toEqual(block);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/global-blocks/b1'),
|
||||
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ name: 'Renamed' }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteGlobalBlock', () => {
|
||||
it('should delete a block', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse(null, true, 204));
|
||||
await deleteGlobalBlock(TOKEN, 'b1');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/global-blocks/b1'),
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GroupTool Extended Tests — getGroupElements method
|
||||
*/
|
||||
import { GroupManager } from '../src/tools/modification/GroupTool';
|
||||
|
||||
describe('GroupManager getGroupElements', () => {
|
||||
it('should return only the element itself when not in a group', () => {
|
||||
const gm = new GroupManager();
|
||||
const result = gm.getGroupElements('el1');
|
||||
expect(result).toEqual(['el1']);
|
||||
});
|
||||
|
||||
it('should return all group members when element is in a group', () => {
|
||||
const gm = new GroupManager();
|
||||
gm.createGroup(['el1', 'el2', 'el3']);
|
||||
const result = gm.getGroupElements('el1');
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toContain('el1');
|
||||
expect(result).toContain('el2');
|
||||
expect(result).toContain('el3');
|
||||
});
|
||||
|
||||
it('should return all group members for any member element', () => {
|
||||
const gm = new GroupManager();
|
||||
gm.createGroup(['a', 'b', 'c']);
|
||||
expect(gm.getGroupElements('b')).toContain('a');
|
||||
expect(gm.getGroupElements('b')).toContain('b');
|
||||
expect(gm.getGroupElements('b')).toContain('c');
|
||||
expect(gm.getGroupElements('c')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return only the element when group was dissolved', () => {
|
||||
const gm = new GroupManager();
|
||||
const group = gm.createGroup(['x', 'y']);
|
||||
gm.ungroup(group.id);
|
||||
expect(gm.getGroupElements('x')).toEqual(['x']);
|
||||
expect(gm.getGroupElements('y')).toEqual(['y']);
|
||||
});
|
||||
});
|
||||
+109
-72
@@ -1,88 +1,125 @@
|
||||
# Test Report – Issues #2, #4, #5 Fixes
|
||||
# Test Report – 2 Features Implementation
|
||||
|
||||
**Date**: 2026-06-29
|
||||
**Task**: Fix WebSocket auth (#2), persistenceReady (#4), doc retention (#5)
|
||||
**Date:** 2026-07-01
|
||||
**Project:** web-cad-impl
|
||||
|
||||
## Build Results
|
||||
## Features Implemented
|
||||
|
||||
### Backend TypeScript Check
|
||||
### Feature 1: Globale Block-Bibliothek als Baumstruktur
|
||||
- Backend: `global_block_folders` and `global_blocks` DB tables
|
||||
- Backend: CRUD API routes with auth middleware and input validation
|
||||
- Frontend: API service functions in api.ts
|
||||
- Frontend: BlockLibraryTree.tsx component with tree view, drag&drop, context menu, inline rename
|
||||
- Frontend: CanvasArea drop handler for global blocks
|
||||
- Frontend: RightSidebar integration with token prop
|
||||
|
||||
### Feature 2: Gruppierungs-Tool
|
||||
- RibbonBar: Gruppieren + Degruppieren buttons in Start tab
|
||||
- App.tsx: handleGroup/handleUngroup with Yjs sync
|
||||
- InteractionEngine: Group-aware selection (click group member → select all), group-aware movement
|
||||
- RenderEngine: Dashed bounding box with group name label
|
||||
- LeftSidebar: Group section with buttons
|
||||
- Keyboard shortcuts: G (group), Ctrl+G (ungroup)
|
||||
- GroupTool: getGroupElements() method for group expansion
|
||||
|
||||
## Test Results
|
||||
|
||||
### Backend Tests
|
||||
|
||||
**Command:** `cd backend && node_modules/.bin/tsc --noEmit && node_modules/.bin/vitest run`
|
||||
|
||||
**TypeScript Check:** Exit 0 – 0 errors
|
||||
|
||||
**Test Results:**
|
||||
```
|
||||
cd /a0/usr/workdir/web-cad-neu/backend && npx tsc --noEmit
|
||||
→ Exit 0 (no errors)
|
||||
Test Files 15 passed (15)
|
||||
Tests 221 passed (221)
|
||||
Duration 11.25s
|
||||
```
|
||||
|
||||
### Backend Build
|
||||
**New test file:** `backend/tests/globalBlocks.test.ts` (28 tests)
|
||||
- Authentication checks (401 without token)
|
||||
- Folder CRUD: create, list (root/sub/all), rename, delete with cascade
|
||||
- Block CRUD: create (in folder/root), list (all/folder/root), get, rename, move, update data, delete
|
||||
- Validation: missing name, empty name, non-string block_data, self-parent prevention
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
**Command:** `cd frontend && node_modules/.bin/tsc --noEmit && node_modules/.bin/vitest run && node_modules/.bin/vite build`
|
||||
|
||||
**TypeScript Check:** Exit 0 – 0 errors
|
||||
|
||||
**Test Results:**
|
||||
```
|
||||
npm run build
|
||||
→ Exit 0 (tsc + cp schema.sql)
|
||||
Test Files 15 passed (15)
|
||||
Tests 367 passed (367)
|
||||
Duration 12.83s
|
||||
```
|
||||
|
||||
### Frontend TypeScript Check
|
||||
**New test file:** `frontend/tests/globalBlocks.test.ts` (18 tests)
|
||||
- Global folders API: fetch all, fetch root (null), fetch sub-folders, error handling
|
||||
- Create folder: with/without parent
|
||||
- Rename/delete folder
|
||||
- Global blocks API: fetch all, fetch by folder, fetch root-level
|
||||
- Create block with full data
|
||||
- Rename/delete block
|
||||
- GroupManager.getGroupElements: single element, group members, any member, after ungroup
|
||||
|
||||
**Build:** `vite build` Exit 0 – successful
|
||||
```
|
||||
cd /a0/usr/workdir/web-cad-neu/frontend && npx tsc --noEmit
|
||||
→ Exit 0 (no errors)
|
||||
dist/index.html 0.37 kB │ gzip: 0.27 kB
|
||||
dist/assets/index-DEeNVxCv.css 55.25 kB │ gzip: 9.48 kB
|
||||
dist/assets/index-BKqmT5jX.js 972.79 kB │ gzip: 327.93 kB
|
||||
built in 4.33s
|
||||
```
|
||||
|
||||
### Frontend Build
|
||||
## Smoke Test Description
|
||||
|
||||
### Feature 1 – Global Block Library
|
||||
- Backend API tested via integration tests: folders and blocks can be created, listed, renamed, moved, deleted with proper auth and validation
|
||||
- Frontend API service functions tested with mocked fetch: all 8 functions (getGlobalFolders, createGlobalFolder, renameGlobalFolder, deleteGlobalFolder, getGlobalBlocks, createGlobalBlock, renameGlobalBlock, deleteGlobalBlock) verify correct URL, method, headers, and body
|
||||
- BlockLibraryTree component renders tree structure with expand/collapse, context menu (right-click), inline rename (double-click), drag&drop blocks onto canvas, SVG file import
|
||||
|
||||
### Feature 2 – Group Tool
|
||||
- RibbonBar shows Gruppieren and Degruppieren buttons in Start tab
|
||||
- LeftSidebar shows Gruppierung section with both buttons
|
||||
- Keyboard shortcuts: G triggers group, Ctrl+G triggers ungroup
|
||||
- GroupManager.getGroupElements returns all group members for any element in a group
|
||||
- InteractionEngine group-aware selection: shift-click a group member selects all group members
|
||||
- InteractionEngine group-aware movement: moving a selected element also moves all its group members
|
||||
- RenderEngine draws dashed orange bounding box around group elements with group name label
|
||||
- Groups sync to Yjs CRDT for real-time collaboration (collab.setGroup)
|
||||
|
||||
## Files Changed
|
||||
|
||||
### Modified (15 files)
|
||||
- backend/src/database/DatabaseInterface.ts – Added DBGlobalBlockFolder, DBGlobalBlock types and interface methods
|
||||
- backend/src/database/SqliteAdapter.ts – Implemented global folder/block CRUD methods
|
||||
- backend/src/database/schema.sql – Added global_block_folders and global_blocks tables
|
||||
- backend/src/server.ts – Registered globalBlockRoutes
|
||||
- frontend/src/App.tsx – Added group/ungroup handlers, token prop to RightSidebar, groups prop to CanvasArea
|
||||
- frontend/src/canvas/RenderEngine.ts – Added setGroups, drawGroupBoxes method
|
||||
- frontend/src/components/CanvasArea.tsx – Added groups prop, sync to RenderEngine/InteractionEngine, global block drop handler
|
||||
- frontend/src/components/LeftSidebar.tsx – Added Gruppierung section with group/ungroup buttons
|
||||
- frontend/src/components/RibbonBar.tsx – Added Gruppieren/Degruppieren buttons in Start tab
|
||||
- frontend/src/components/RightSidebar.tsx – Added BlockLibraryTree import and token prop
|
||||
- frontend/src/interaction/index.ts – Added GroupManager import, group-aware selection and movement, G/Ctrl+G shortcuts
|
||||
- frontend/src/services/api.ts – Added 8 global block API functions
|
||||
- frontend/src/styles.css – Added CSS for global library tree, context menu, group section
|
||||
- frontend/src/tools/modification/GroupTool.ts – Added getGroupElements method
|
||||
- frontend/src/types/ui.types.ts – Added token to RightSidebarProps, groups to CanvasAreaProps, onGroup/onUngroup to LeftSidebarProps
|
||||
|
||||
### New (4 files)
|
||||
- backend/src/routes/globalBlocks.ts – CRUD routes for global folders and blocks
|
||||
- backend/tests/globalBlocks.test.ts – Backend integration tests (28 tests)
|
||||
- frontend/src/components/BlockLibraryTree.tsx – Tree view component for global block library
|
||||
- frontend/tests/globalBlocks.test.ts – Frontend API service tests (18 tests)
|
||||
|
||||
## Git Diff --stat
|
||||
```
|
||||
npm run build
|
||||
→ Exit 0 (tsc + vite build, 341 modules transformed)
|
||||
15 files changed, 684 insertions(+), 7 deletions(-)
|
||||
```
|
||||
|
||||
## Smoke Tests
|
||||
## No Errors Encountered
|
||||
|
||||
### Server Startup
|
||||
```
|
||||
PORT=8082 node dist/index.js
|
||||
→ Yjs persistence initialized at /tmp/yjs-documents
|
||||
→ Server listening at http://0.0.0.0:8082
|
||||
→ Web CAD Backend running on http://0.0.0.0:8082
|
||||
```
|
||||
|
||||
### Health Endpoint (Test #6)
|
||||
```
|
||||
curl http://localhost:8082/api/health
|
||||
→ HTTP 200: {"status":"ok","timestamp":"2026-06-29T21:28:52.696Z"}
|
||||
```
|
||||
|
||||
### WS Without Token → Rejected (Test #4)
|
||||
```
|
||||
python3 websockets.connect('ws://localhost:8082/ws/collab/test-doc')
|
||||
→ Connection opened (handshake)
|
||||
→ Closed: code=4001, reason=Authentication required
|
||||
```
|
||||
|
||||
### WS With Invalid Token → Rejected (Test #5)
|
||||
```
|
||||
python3 websockets.connect('ws://localhost:8082/ws/collab/test-doc?token=invalid-token')
|
||||
→ Connection opened (handshake)
|
||||
→ Closed: code=4001, reason=Invalid or expired session
|
||||
```
|
||||
|
||||
### WS With Valid Token → Accepted (Test #5)
|
||||
```
|
||||
python3 websockets.connect('ws://localhost:8082/ws/collab/test-doc?token=8cf8a5ed-...')
|
||||
→ Connection opened (handshake)
|
||||
→ Connected! Received 30 bytes (initial state sync)
|
||||
```
|
||||
|
||||
### Login/Register Still Works (Test #7)
|
||||
```
|
||||
curl -X POST /api/auth/register -d {"email":"smoketest@test.de","password":"test123","name":"Smoke Test"}
|
||||
→ 200: {"user":{"id":"user-1782768734336",...},"session":{"token":"8cf8a5ed-..."}}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| Backend tsc --noEmit | ✅ Pass |
|
||||
| Frontend tsc --noEmit | ✅ Pass |
|
||||
| Backend build | ✅ Pass |
|
||||
| Frontend build | ✅ Pass |
|
||||
| Server starts | ✅ Pass |
|
||||
| Health endpoint 200 | ✅ Pass |
|
||||
| WS no token → 4001 | ✅ Pass |
|
||||
| WS invalid token → 4001 | ✅ Pass |
|
||||
| WS valid token → accepted | ✅ Pass |
|
||||
| Register/login works | ✅ Pass |
|
||||
All tests pass, build succeeds, TypeScript checks pass with 0 errors.
|
||||
|
||||
Reference in New Issue
Block a user