diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f4f76c..c06db20 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1088,6 +1088,12 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack // Layer handlers const handleSelectLayer = useCallback((id: string) => setActiveLayerId(id), []); + + const handleSelectElement = useCallback((id: string) => { + setSelectedElementIds([id]); + const el = elements.find(e => e.id === id); + setSelectedElement(el ?? null); + }, [elements]); const handleAddLayer = useCallback(() => { setLayers((prev) => { const newId = `layer-${Date.now()}`; @@ -1521,6 +1527,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack blocks={blocks} onBlockDrop={handleBlockDrop} onSelectionChange={handleSelectionChange} + externalSelectionIds={selectedElementIds} selectedTemplate={selectedTemplate} bgConfig={bgConfig} remoteCursors={collab.cursors} @@ -1563,6 +1570,9 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack onReorder={handleReorderLayer} onAddSubLayer={handleAddSubLayer} onUpdateElement={handleUpdateElement} + selectedElementIds={selectedElementIds} + groups={groups} + onSelectElement={handleSelectElement} token={token} /> diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index debabc6..19e1f7e 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -16,7 +16,7 @@ const CanvasArea: React.FC = ({ 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, + onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit, onTextEdit, onCommandTrigger, blocks, onBlockDrop, onSelectionChange, externalSelectionIds, selectedTemplate, bgConfig, remoteCursors, zoomCommand, groups, }) => { const canvasRef = useRef(null); @@ -25,6 +25,7 @@ const CanvasArea: React.FC = ({ const interactionRef = useRef(null); const spatialIndexRef = useRef(null); const layerManagerRef = useRef(null); + const selectionEngineRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; @@ -45,6 +46,7 @@ const CanvasArea: React.FC = ({ interactionRef.current = interaction; spatialIndexRef.current = spatialIndex; layerManagerRef.current = layerManager; + selectionEngineRef.current = selectionEngine; interaction.setCallbacks({ onElementCreated, @@ -66,6 +68,7 @@ const CanvasArea: React.FC = ({ interactionRef.current = null; spatialIndexRef.current = null; layerManagerRef.current = null; + selectionEngineRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -169,6 +172,28 @@ const CanvasArea: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [groups]); + // Sync external selection (from tree panel) to SelectionEngine + const prevExternalSelectionRef = useRef([]); + useEffect(() => { + const selectionEngine = selectionEngineRef.current; + const renderEngine = renderEngineRef.current; + if (!selectionEngine || !renderEngine) return; + if (!externalSelectionIds || externalSelectionIds.length === 0) return; + // Avoid feedback loop: skip if selection is already identical + const currentIds = Array.from(selectionEngine.getSelectedIds()).sort(); + const incomingIds = [...externalSelectionIds].sort(); + const same = currentIds.length === incomingIds.length && currentIds.every((id, i) => id === incomingIds[i]); + if (same) return; + // Avoid re-processing the same external selection we just applied + const prevIds = prevExternalSelectionRef.current; + const sameAsPrev = prevIds.length === incomingIds.length && prevIds.every((id, i) => id === incomingIds[i]); + if (sameAsPrev) return; + prevExternalSelectionRef.current = incomingIds; + selectionEngine.selectByIds(externalSelectionIds, false); + renderEngine.render(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [externalSelectionIds]); + // Sync active layer to LayerManager useEffect(() => { const layerManager = layerManagerRef.current; diff --git a/frontend/src/components/LayerPanel.tsx b/frontend/src/components/LayerPanel.tsx index 706878b..29c20b7 100644 --- a/frontend/src/components/LayerPanel.tsx +++ b/frontend/src/components/LayerPanel.tsx @@ -32,6 +32,17 @@ const layerIcon = ( ); +const groupIcon = ( + + + + + + + + +); + const elementIcons: Record = { line: , rect: , @@ -58,7 +69,10 @@ const LayerPanel: React.FC = ({ layers, elements = [], activeLayerId, + selectedElementIds = [], + groups = [], onSelectLayer, + onSelectElement, onAddLayer, onToggleLayer, onDeleteLayer, @@ -76,22 +90,71 @@ const LayerPanel: React.FC = ({ .sort((a, b) => a.sortOrder - b.sortOrder) .map((l) => { const layerElements = elements.filter((e) => e.layerId === l.id); - const elementNodes: TreeNode[] = layerElements.map((e) => ({ + + // Build group nodes for this layer + const layerGroups = groups.filter((g) => + g.elementIds.some((eid) => layerElements.some((e) => e.id === eid)) + ); + const groupNodes: TreeNode[] = layerGroups.map((g) => { + const groupElementIds = g.elementIds.filter((eid) => + layerElements.some((e) => e.id === eid) + ); + const groupElementsList = groupElementIds + .map((eid) => elements.find((e) => e.id === eid)) + .filter((e): e is CADElement => e !== undefined); + + const groupChildNodes: TreeNode[] = groupElementsList.map((e) => ({ + id: e.id, + name: elementTypeLabels[e.type] || e.type, + icon: elementIcons[e.type] || defaultIcon, + expanded: false, + active: selectedElementIds.includes(e.id), + children: [], + })); + + // A group is "active" if any of its elements are selected + const groupActive = groupElementIds.some((eid) => selectedElementIds.includes(eid)); + + return { + id: g.id, + name: g.name, + icon: groupIcon, + expanded: false, + active: groupActive, + children: groupChildNodes, + count: groupElementIds.length > 0 ? groupElementIds.length : undefined, + } as TreeNode; + }); + + // Build element nodes — only elements NOT in any group go directly under layer + const groupedElementIds = new Set(); + for (const g of layerGroups) { + for (const eid of g.elementIds) { + if (layerElements.some((e) => e.id === eid)) { + groupedElementIds.add(eid); + } + } + } + const ungroupedElements = layerElements.filter((e) => !groupedElementIds.has(e.id)); + const elementNodes: TreeNode[] = ungroupedElements.map((e) => ({ id: e.id, name: elementTypeLabels[e.type] || e.type, icon: elementIcons[e.type] || defaultIcon, expanded: false, - active: false, + active: selectedElementIds.includes(e.id), children: [], })); + const subLayerNodes = buildTree(l.id); + + // Groups appear above elements, sublayers appear first return { id: l.id, name: l.name, icon: layerIcon, expanded: false, active: l.id === activeLayerId, - children: [...subLayerNodes, ...elementNodes], + children: [...subLayerNodes, ...groupNodes, ...elementNodes], count: layerElements.length > 0 ? layerElements.length : undefined, } as TreeNode; }); @@ -99,6 +162,24 @@ const LayerPanel: React.FC = ({ const tree = buildTree(null); + // Determine which IDs are layer IDs vs element IDs vs group IDs for dispatch + const layerIds = new Set(layers.map((l) => l.id)); + const groupIds = new Set(groups.map((g) => g.id)); + + const handleSelect = (id: string) => { + if (layerIds.has(id)) { + onSelectLayer(id); + } else if (groupIds.has(id)) { + const group = groups.find((g) => g.id === id); + if (group && group.elementIds.length > 0) { + onSelectElement?.(group.elementIds[0]); + } + } else { + // It's an element ID + onSelectElement?.(id); + } + }; + const handleAddSubLayer = (parentId: string) => { if (onAddSubLayer) { onAddSubLayer(parentId); @@ -131,7 +212,8 @@ const LayerPanel: React.FC = ({ node.icon} diff --git a/frontend/src/components/RightSidebar.tsx b/frontend/src/components/RightSidebar.tsx index 2b3a7ba..918ec67 100644 --- a/frontend/src/components/RightSidebar.tsx +++ b/frontend/src/components/RightSidebar.tsx @@ -24,6 +24,9 @@ const RightSidebar: React.FC = ({ onRenameBlock, onDuplicateBlock, onDeleteBlock, onSvgImport, onSaveGroupAsBlock, onBlockCategoryChange, onBlockSearch, onDragBlock, kiMessages, kiSuggestions, onKISend, onKISuggestionClick, kiLoading, onUpdateElement, + selectedElementIds, + groups, + onSelectElement, onCollapse, collapsed, onToggleCollapse, @@ -79,7 +82,7 @@ const RightSidebar: React.FC = ({ }} />}
- {activePanel === 'layer' && {})} onAddLayer={onAddLayer ?? (() => {})} onToggleLayer={onToggleLayer ?? (() => {})} onDeleteLayer={onDeleteLayer} onRenameLayer={onRenameLayer} onDuplicateLayer={onDuplicateLayer} onToggleLock={onToggleLock} onReorder={onReorder} onAddSubLayer={onAddSubLayer} />} + {activePanel === 'layer' && {})} onSelectElement={onSelectElement} selectedElementIds={selectedElementIds} groups={groups} onAddLayer={onAddLayer ?? (() => {})} onToggleLayer={onToggleLayer ?? (() => {})} onDeleteLayer={onDeleteLayer} onRenameLayer={onRenameLayer} onDuplicateLayer={onDuplicateLayer} onToggleLock={onToggleLock} onReorder={onReorder} onAddSubLayer={onAddSubLayer} />}
{activePanel === 'library' && ( diff --git a/frontend/src/components/TreeView.tsx b/frontend/src/components/TreeView.tsx index 4176364..319b768 100644 --- a/frontend/src/components/TreeView.tsx +++ b/frontend/src/components/TreeView.tsx @@ -4,6 +4,7 @@ import type { TreeNode } from '../types/ui.types'; interface TreeViewProps { nodes: TreeNode[]; selectedId?: string | null; + selectedIds?: string[]; onSelect: (id: string) => void; onToggle?: (id: string) => void; onReorder?: (draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => void; @@ -16,6 +17,7 @@ interface TreeViewProps { const TreeView: React.FC = ({ nodes, selectedId, + selectedIds, onSelect, onToggle, onReorder, @@ -87,7 +89,7 @@ const TreeView: React.FC = ({ const renderNode = (node: TreeNode, level: number): React.ReactNode => { const hasChildren = node.children && node.children.length > 0; const isExpanded = expandedSet.has(node.id) || node.expanded; - const isSelected = selectedId === node.id; + const isSelected = selectedId === node.id || (selectedIds !== undefined && selectedIds.includes(node.id)); const isDragOver = dragOverId === node.id; const isDragging = draggedId === node.id; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 39d13d5..ee1b8cc 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -253,7 +253,7 @@ a:hover { text-decoration: underline; } .project-name:hover { background: var(--color-surface-2); } .project-name .caret { color: var(--color-text-faint); } .saved-badge { - font-size: var(--fs-xs); + font-size: var(--fs-sm); color: var(--color-text-muted); padding: 2px 8px; background: var(--color-surface-2); @@ -355,7 +355,7 @@ a:hover { text-decoration: underline; } gap: 6px; padding: 0 12px; height: 100%; - font-size: var(--fs-sm); + font-size: var(--fs-md); color: var(--color-text-muted); border-bottom: 2px solid transparent; margin-bottom: -1px; @@ -385,8 +385,8 @@ a:hover { text-decoration: underline; } } .ribbon-group-btns { display: flex; align-items: center; gap: 2px; flex: 1; } .ribbon-group-label { - font-size: 10px; - color: var(--color-text-faint); + font-size: 12px; + color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.4px; border-top: 1px solid var(--color-border); @@ -414,7 +414,7 @@ a:hover { text-decoration: underline; } padding: 4px 6px; border-radius: var(--radius-sm); color: var(--color-text); - font-size: 10px; + font-size: 12px; font-weight: 500; transition: all var(--t-fast); flex-shrink: 0; diff --git a/frontend/src/types/ui.types.ts b/frontend/src/types/ui.types.ts index 6d175a8..99f187b 100644 --- a/frontend/src/types/ui.types.ts +++ b/frontend/src/types/ui.types.ts @@ -132,6 +132,7 @@ export interface CanvasAreaProps { blocks?: BlockDefinition[]; onBlockDrop?: (blockId: string, x: number, y: number) => void; onSelectionChange?: (selectedIds: string[]) => void; + externalSelectionIds?: string[]; selectedTemplate?: string | null; bgConfig?: BackgroundConfig | null; remoteCursors?: UserCursor[]; @@ -174,6 +175,9 @@ export interface RightSidebarProps { onKISuggestionClick?: (suggestion: KISuggestion) => void; kiLoading?: boolean; onUpdateElement?: (el: CADElement) => void; + selectedElementIds?: string[]; + groups?: Array<{ id: string; name: string; elementIds: string[]; parentGroupId: string | null }>; + onSelectElement?: (id: string) => void; token?: string; className?: string; onCollapse?: () => void; @@ -192,7 +196,10 @@ export interface LayerPanelProps { layers: CADLayer[]; elements?: CADElement[]; activeLayerId?: string; + selectedElementIds?: string[]; + groups?: Array<{ id: string; name: string; elementIds: string[]; parentGroupId: string | null }>; onSelectLayer: (id: string) => void; + onSelectElement?: (id: string) => void; onAddLayer: () => void; onToggleLayer: (id: string) => void; onDeleteLayer?: (id: string) => void; diff --git a/frontend/tests/Components.test.tsx b/frontend/tests/Components.test.tsx index fe9da9a..aeeaab5 100644 --- a/frontend/tests/Components.test.tsx +++ b/frontend/tests/Components.test.tsx @@ -238,6 +238,112 @@ describe('LayerPanel', () => { fireEvent.click(toggleBtn); expect(onToggleLayer).toHaveBeenCalled(); }); + // Helper: expand a tree node by clicking its toggle button + function expandLayer(label: string) { + const layerNode = screen.getByText(label).closest('.tree-node'); + expect(layerNode).toBeTruthy(); + const toggle = layerNode?.querySelector('.tree-toggle'); + expect(toggle).toBeTruthy(); + fireEvent.click(toggle!); + } + + it('should highlight selected element in tree when selectedElementIds is provided', () => { + const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })]; + const elements = [ + makeElement({ id: 'elem-1', type: 'rect', layerId: 'layer-1' }), + makeElement({ id: 'elem-2', type: 'circle', layerId: 'layer-1' }), + ]; + const { container } = render( + {}} + onAddLayer={() => {}} + onToggleLayer={() => {}} + />, + ); + expandLayer('Layer 1'); + // The element node for elem-2 should have the 'active' class + const treeNodes = container.querySelectorAll('.tree-node'); + const elemNode = Array.from(treeNodes).find((n) => n.textContent?.includes('Kreis')); + expect(elemNode).toBeTruthy(); + expect(elemNode?.classList.contains('active')).toBe(true); + }); + + it('should call onSelectElement when clicking an element in the tree', () => { + const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })]; + const elements = [ + makeElement({ id: 'elem-1', type: 'rect', layerId: 'layer-1' }), + ]; + const onSelectElement = vi.fn(); + render( + {}} + onSelectElement={onSelectElement} + onAddLayer={() => {}} + onToggleLayer={() => {}} + />, + ); + expandLayer('Layer 1'); + fireEvent.click(screen.getByText('Rechteck')); + expect(onSelectElement).toHaveBeenCalledWith('elem-1'); + }); + + it('should render groups as tree nodes with their elements as children', () => { + const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })]; + const elements = [ + makeElement({ id: 'elem-1', type: 'rect', layerId: 'layer-1' }), + makeElement({ id: 'elem-2', type: 'circle', layerId: 'layer-1' }), + ]; + const groups = [ + { id: 'group-1', name: 'Gruppe A', elementIds: ['elem-1', 'elem-2'], parentGroupId: null }, + ]; + render( + {}} + onAddLayer={() => {}} + onToggleLayer={() => {}} + />, + ); + expandLayer('Layer 1'); + expect(screen.getByText('Gruppe A')).toBeInTheDocument(); + }); + + it('should select all group elements when clicking a group node', () => { + const layers = [makeLayer({ id: 'layer-1', name: 'Layer 1' })]; + const elements = [ + makeElement({ id: 'elem-1', type: 'rect', layerId: 'layer-1' }), + makeElement({ id: 'elem-2', type: 'circle', layerId: 'layer-1' }), + ]; + const groups = [ + { id: 'group-1', name: 'Gruppe A', elementIds: ['elem-1', 'elem-2'], parentGroupId: null }, + ]; + const onSelectElement = vi.fn(); + render( + {}} + onSelectElement={onSelectElement} + onAddLayer={() => {}} + onToggleLayer={() => {}} + />, + ); + expandLayer('Layer 1'); + fireEvent.click(screen.getByText('Gruppe A')); + expect(onSelectElement).toHaveBeenCalledWith('elem-1'); + }); }); // ─── PropertiesPanel ─────────────────────────────────