Files
web-cad/frontend/src/components/BlockLibraryTree.tsx
T

664 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useCallback } from 'react';
import type { GlobalBlockFolder, GlobalBlock } from '../services/api';
import { importSVGAsBlockPayload } from '../services/importService';
import {
getGlobalFolders,
createGlobalFolder,
renameGlobalFolder,
deleteGlobalFolder,
getGlobalBlocks,
createGlobalBlock,
renameGlobalBlock,
deleteGlobalBlock,
setGlobalBlockFavorite,
exportLibrary,
importLibrary,
exportLibraryZip,
importLibraryZip,
} from '../services/api';
import { getActiveDocument } from '../kernel/document/documentService';
import { generateBlockSvg } from '../utils/blockThumbnail';
import type { CADElement } from '../types/cad.types';
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 wcadInputRef = useRef<HTMLInputElement>(null);
const zipInputRef = useRef<HTMLInputElement>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
// ─── Task F2: Suche, Favoriten, Thumbnail-Grid ───────────
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<'tree' | 'grid'>('tree');
const handleToggleFavorite = useCallback(async (blockId: string, next: boolean) => {
try {
const updated = await setGlobalBlockFavorite(token, blockId, next);
setBlocks(prev => prev.map(b => (b.id === blockId ? { ...b, is_favorite: updated.is_favorite } : b)));
} catch {
// silently ignore — favorite stays unchanged on failure
}
}, [token]);
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]);
// ─── Task F3: Auswahl als Block speichern ─────────────────
const handleSaveSelectionAsBlock = useCallback(async () => {
const bridge = (window as unknown as { __v2GetSelection?: () => { ids: string[] } }).__v2GetSelection;
const ids = bridge?.().ids ?? [];
if (ids.length === 0) {
window.alert('Keine Auswahl im Canvas — erst Elemente wählen (V2-Tools).');
return;
}
const doc = getActiveDocument();
const elements: CADElement[] = [];
for (const id of ids) {
const el = doc?.getElement(id);
if (el) elements.push(el);
}
if (elements.length === 0) {
window.alert('Auswahl enthält keine Elemente.');
return;
}
const name = window.prompt('Blockname:', `Block-${new Date().toISOString().slice(0, 10)}`);
if (!name?.trim()) return;
try {
await createGlobalBlock(token, {
name: name.trim(),
block_data: JSON.stringify(elements),
svg_data: generateBlockSvg(elements),
});
await loadData();
} catch (err) {
window.alert(`Speichern fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
}
}, [token, loadData]);
// ─── Task F1: Library-Paket Export/Import ────────────────
const handleZipExport = useCallback(async () => {
try {
const blob = await exportLibraryZip(token);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `web-cad-library-${new Date().toISOString().slice(0, 10)}.wcadlib.zip`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
alert(`ZIP-Export fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
}
}, [token]);
const handleZipImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const result = await importLibraryZip(token, file);
alert(`Bibliothek importiert: ${result.imported_blocks} Blöcke, ${result.imported_folders} Ordner`);
await loadData();
} catch (err) {
alert(`ZIP-Import fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
} finally {
e.target.value = '';
}
}, [token, loadData]);
const handleLibraryExport = useCallback(async () => {
try {
const pkg = await exportLibrary(token);
const blob = new Blob([JSON.stringify(pkg, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `web-cad-library-${new Date().toISOString().slice(0, 10)}.wcadlib`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
alert(`Library-Export fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
}
}, [token]);
const handleWcadFile = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const pkg = JSON.parse(text);
const result = await importLibrary(token, pkg);
alert(`Bibliothek importiert: ${result.imported_blocks} Blöcke, ${result.imported_folders} Ordner`);
await loadData();
} catch (err) {
alert(`Library-Import fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
} finally {
e.target.value = '';
}
}, [token, 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 q = searchQuery.toLowerCase().trim();
const matchesSearch = useCallback((b: GlobalBlock) => !q || b.name.toLowerCase().includes(q), [q]);
const rootBlocks = blocks.filter(b => !b.folder_id && matchesSearch(b)).sort((a, b) => a.name.localeCompare(b.name));
const favoriteBlocks = blocks.filter(b => b.is_favorite && matchesSearch(b)).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 svgPayload = importSVGAsBlockPayload(svgContent);
const created = await createGlobalBlock(token, {
name: file.name.replace(/\.svg$/i, ''),
folder_id: folderId,
block_data: svgPayload.blockData ?? 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 svgPayload = importSVGAsBlockPayload(svgContent);
const created = await createGlobalBlock(token, {
name: file.name.replace(/\.svg$/i, ''),
block_data: svgPayload.blockData ?? 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('');
};
// ─── Task F2: Block-Leaf mit Thumbnail + Favorite-Stern ──
const renderBlockLeaf = (block: GlobalBlock, depth: number): React.ReactNode => {
const isBlockRenaming = renamingId === block.id;
return (
<div
key={block.id}
className="tree-item tree-item-leaf draggable"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
draggable
onDragStart={(e) => handleBlockDragStart(e, block)}
onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)}
title={block.name}
>
{block.svg_data ? (
<span className="tree-thumb" dangerouslySetInnerHTML={{ __html: block.svg_data }} />
) : (
<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>
)}
<button
className={`tree-fav-btn${block.is_favorite ? ' active' : ''}`}
title={block.is_favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => { e.stopPropagation(); void handleToggleFavorite(block.id, !block.is_favorite); }}
>
{block.is_favorite ? '★' : '☆'}
</button>
</div>
);
};
// Task F2: Grid-Zelle mit Thumbnail
const renderGridCell = (block: GlobalBlock): React.ReactNode => (
<div
key={block.id}
className="lib-grid-cell"
draggable
onDragStart={(e) => handleBlockDragStart(e, block)}
onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)}
title={`${block.name}${block.is_favorite ? ' ★' : ''}`}
>
<div className="lib-grid-thumb">
{block.svg_data
? <span className="tree-thumb" dangerouslySetInnerHTML={{ __html: block.svg_data }} />
: <span className="lib-grid-placeholder">📦</span>}
</div>
<div className="lib-grid-name">{block.name}</div>
<button
className={`tree-fav-btn${block.is_favorite ? ' active' : ''}`}
title={block.is_favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => { e.stopPropagation(); void handleToggleFavorite(block.id, !block.is_favorite); }}
>
{block.is_favorite ? '★' : '☆'}
</button>
</div>
);
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.filter(matchesSearch).map(block => renderBlockLeaf(block, depth + 1))}
</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="Canvas-Auswahl als Block speichern (F3)"
onClick={() => void handleSaveSelectionAsBlock()}
>
💾
</button>
<button
className="global-lib-add-btn"
title="Bibliothek als .wcadlib exportieren"
onClick={() => void handleLibraryExport()}
>
<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="7 10 12 17 17 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</button>
<button
className="global-lib-add-btn"
title=".wcadlib-Bibliothek importieren"
onClick={() => wcadInputRef.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="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button
className="global-lib-add-btn"
title="Bibliothek als ZIP-Paket exportieren (Task F7)"
onClick={handleZipExport}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 8v13H3V8"/><rect x="1" y="3" width="22" height="5"/><path d="M10 12h4"/></svg>
</button>
<button
className="global-lib-add-btn"
title=".wcadlib-ZIP-Paket importieren (Task F7)"
onClick={() => zipInputRef.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 8v13H3V8"/><rect x="1" y="3" width="22" height="5"/><path d="M15 12l-3-3-3 3"/><path d="M12 9v8"/></svg>
</button>
<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} />
<input ref={wcadInputRef} type="file" accept=".wcadlib,application/json" style={{ display: 'none' }} onChange={handleWcadFile} />
<input
type="file"
accept=".zip,.wcadlib.zip,application/zip"
ref={zipInputRef}
style={{ display: 'none' }}
onChange={handleZipImport}
/>
</div>
<div className="global-lib-toolbar">
<input
type="text"
className="global-lib-search"
placeholder="Blöcke suchen…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<button
className="global-lib-add-btn"
title={viewMode === 'grid' ? 'Baumansicht' : 'Rasteransicht'}
onClick={() => setViewMode(m => (m === 'grid' ? 'tree' : 'grid'))}
>
{viewMode === 'grid' ? '☰' : '▦'}
</button>
</div>
<div className="tree tree-global" role="tree" aria-label="Globale Block-Bibliothek">
{favoriteBlocks.length > 0 && (
<div className="lib-fav-section">
<div className="lib-fav-title"> Favoriten</div>
{favoriteBlocks.map(block => renderBlockLeaf(block, 0))}
</div>
)}
{viewMode === 'grid' ? (
<div className="lib-grid">
{[...favoriteBlocks, ...blocks.filter(b => !b.is_favorite && matchesSearch(b)).sort((a, b) => a.name.localeCompare(b.name))].map(renderGridCell)}
</div>
) : (
<>
{tree.map(node => renderFolderNode(node, 0))}
{rootBlocks.map(block => renderBlockLeaf(block, 0))}
</>
)}
{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;