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 = ({ token, onBlockDragStart }) => { const [folders, setFolders] = useState([]); const [blocks, setBlocks] = useState([]); const [expandedIds, setExpandedIds] = useState>(new Set()); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'folder' | 'block' | 'root'; id: string | null; parentId: string | null; } | null>(null); const [loading, setLoading] = useState(true); const fileInputRef = useRef(null); const wcadInputRef = useRef(null); const zipInputRef = useRef(null); const [dragOverFolderId, setDragOverFolderId] = useState(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) => { 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) => { 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(); 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) => { 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 (
handleBlockDragStart(e, block)} onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)} title={block.name} > {block.svg_data ? ( ) : ( 📦 )} {isBlockRenaming ? ( setRenameValue(e.target.value)} onBlur={confirmRename} onKeyDown={(e) => { if (e.key === 'Enter') confirmRename(); if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); } }} onClick={(e) => e.stopPropagation()} /> ) : ( { e.stopPropagation(); startRename(block.id, block.name); }} > {block.name} )}
); }; // Task F2: Grid-Zelle mit Thumbnail const renderGridCell = (block: GlobalBlock): React.ReactNode => (
handleBlockDragStart(e, block)} onContextMenu={(e) => handleContextMenu(e, 'block', block.id, block.folder_id)} title={`${block.name}${block.is_favorite ? ' ★' : ''}`} >
{block.svg_data ? : 📦}
{block.name}
); const renderFolderNode = (node: FolderNode, depth: number): React.ReactNode => { const isRenaming = renamingId === node.folder.id; const isDragOver = dragOverFolderId === node.folder.id; return (
toggleExpand(node.folder.id)} onContextMenu={(e) => handleContextMenu(e, 'folder', node.folder.id, node.folder.parent_id)} onDragOver={(e) => handleFolderDragOver(e, node.folder.id)} onDragLeave={() => setDragOverFolderId(null)} onDrop={(e) => handleFolderDrop(e, node.folder.id)} > {node.children.length > 0 || node.blocks.length > 0 ? (node.expanded ? '▾' : '▸') : '·'} 📁 {isRenaming ? ( setRenameValue(e.target.value)} onBlur={confirmRename} onKeyDown={(e) => { if (e.key === 'Enter') confirmRename(); if (e.key === 'Escape') { setRenamingId(null); setRenameValue(''); } }} onClick={(e) => e.stopPropagation()} /> ) : ( { e.stopPropagation(); startRename(node.folder.id, node.folder.name); }} > {node.folder.name} )} {node.blocks.length}
{node.expanded && (
{node.children.map(child => renderFolderNode(child, depth + 1))} {node.blocks.filter(matchesSearch).map(block => renderBlockLeaf(block, depth + 1))}
)}
); }; if (loading) { return
Globale Bibliothek lädt…
; } return (
handleContextMenu(e, 'root', null, null)}>
🌐 Globale Bibliothek
setSearchQuery(e.target.value)} />
{favoriteBlocks.length > 0 && (
★ Favoriten
{favoriteBlocks.map(block => renderBlockLeaf(block, 0))}
)} {viewMode === 'grid' ? (
{[...favoriteBlocks, ...blocks.filter(b => !b.is_favorite && matchesSearch(b)).sort((a, b) => a.name.localeCompare(b.name))].map(renderGridCell)}
) : ( <> {tree.map(node => renderFolderNode(node, 0))} {rootBlocks.map(block => renderBlockLeaf(block, 0))} )} {folders.length === 0 && blocks.length === 0 && (
Globale Bibliothek ist leer — Rechtsklick für Ordner
)}
{contextMenu && (
e.stopPropagation()} > {contextMenu.type === 'root' && ( <> )} {contextMenu.type === 'folder' && contextMenu.id && ( <> )} {contextMenu.type === 'block' && contextMenu.id && ( <> )}
)}
); }; export default BlockLibraryTree;