feat: global block library tree + grouping tool — 19 files, 588 tests green

This commit is contained in:
A0 Orchestrator
2026-07-01 21:02:31 +02:00
parent 405e55e818
commit 7b19a99b24
20 changed files with 2099 additions and 79 deletions
+66 -1
View File
@@ -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
+52
View File
@@ -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;
+67 -3
View File
@@ -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
}
}
}}
/>
+31 -1
View File
@@ -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>
+8
View File
@@ -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>
+15 -1
View File
@@ -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} />}
+55 -1
View File
@@ -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;
+96
View File
@@ -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 ─────────────────────────────────────────
+142
View File
@@ -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).
*/
+4
View File
@@ -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;
+250
View File
@@ -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']);
});
});