feat: global block library tree + grouping tool — 19 files, 588 tests green
This commit is contained in:
+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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user