feat: layer-tree selection sync + group folders in tree + ribbonbar readability

This commit is contained in:
A0 Orchestrator
2026-07-02 00:47:28 +02:00
parent 2eead4c1aa
commit 9f749e8ebd
8 changed files with 247 additions and 12 deletions
+10
View File
@@ -1088,6 +1088,12 @@ const CADEditor: React.FC<CADEditorProps> = ({ 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<CADEditorProps> = ({ 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<CADEditorProps> = ({ projectId, token, onNavigateBack
onReorder={handleReorderLayer}
onAddSubLayer={handleAddSubLayer}
onUpdateElement={handleUpdateElement}
selectedElementIds={selectedElementIds}
groups={groups}
onSelectElement={handleSelectElement}
token={token}
/>
</div>
+26 -1
View File
@@ -16,7 +16,7 @@ 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,
onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit, onTextEdit, onCommandTrigger, blocks, onBlockDrop, onSelectionChange, externalSelectionIds, selectedTemplate, bgConfig, remoteCursors, zoomCommand,
groups,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -25,6 +25,7 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
const interactionRef = useRef<InteractionEngine | null>(null);
const spatialIndexRef = useRef<SpatialIndex | null>(null);
const layerManagerRef = useRef<LayerManager | null>(null);
const selectionEngineRef = useRef<SelectionEngine | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
@@ -45,6 +46,7 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
interactionRef.current = interaction;
spatialIndexRef.current = spatialIndex;
layerManagerRef.current = layerManager;
selectionEngineRef.current = selectionEngine;
interaction.setCallbacks({
onElementCreated,
@@ -66,6 +68,7 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
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<CanvasAreaProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groups]);
// Sync external selection (from tree panel) to SelectionEngine
const prevExternalSelectionRef = useRef<string[]>([]);
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;
+86 -4
View File
@@ -32,6 +32,17 @@ const layerIcon = (
</svg>
);
const groupIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<rect x="7" y="11" width="4" height="4" rx="0.5" />
<rect x="13" y="11" width="4" height="4" rx="0.5" />
<line x1="9" y1="15" x2="9" y2="17" />
<line x1="15" y1="15" x2="15" y2="17" />
<line x1="9" y1="17" x2="15" y2="17" />
</svg>
);
const elementIcons: Record<string, React.ReactNode> = {
line: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="20" x2="20" y2="4"/></svg>,
rect: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="4" width="16" height="16"/></svg>,
@@ -58,7 +69,10 @@ const LayerPanel: React.FC<LayerPanelProps> = ({
layers,
elements = [],
activeLayerId,
selectedElementIds = [],
groups = [],
onSelectLayer,
onSelectElement,
onAddLayer,
onToggleLayer,
onDeleteLayer,
@@ -76,22 +90,71 @@ const LayerPanel: React.FC<LayerPanelProps> = ({
.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: 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<string>();
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: 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<LayerPanelProps> = ({
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<LayerPanelProps> = ({
<TreeView
nodes={tree}
selectedId={activeLayerId}
onSelect={onSelectLayer}
selectedIds={selectedElementIds}
onSelect={handleSelect}
onReorder={onReorder}
draggable={true}
renderIcon={(node) => node.icon}
+4 -1
View File
@@ -24,6 +24,9 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
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<RightSidebarProps> = ({
}} />}
</div>
<div className={`rightbar-panel${activePanel === 'layer' ? ' active' : ''}`} data-panel-content="layer">
{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} />}
{activePanel === 'layer' && <LayerPanel layers={layers} elements={elements} onElementsDeleted={onElementsDeleted} onToggleElementVisible={onToggleElementVisible} activeLayerId={activeLayerId} onSelectLayer={onSelectLayer ?? (() => {})} onSelectElement={onSelectElement} selectedElementIds={selectedElementIds} groups={groups} 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' && (
+3 -1
View File
@@ -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<TreeViewProps> = ({
nodes,
selectedId,
selectedIds,
onSelect,
onToggle,
onReorder,
@@ -87,7 +89,7 @@ const TreeView: React.FC<TreeViewProps> = ({
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;
+5 -5
View File
@@ -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;
+7
View File
@@ -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;
+106
View File
@@ -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(
<LayerPanel
layers={layers}
elements={elements}
selectedElementIds={['elem-2']}
activeLayerId="layer-1"
onSelectLayer={() => {}}
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(
<LayerPanel
layers={layers}
elements={elements}
activeLayerId="layer-1"
onSelectLayer={() => {}}
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(
<LayerPanel
layers={layers}
elements={elements}
groups={groups}
activeLayerId="layer-1"
onSelectLayer={() => {}}
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(
<LayerPanel
layers={layers}
elements={elements}
groups={groups}
activeLayerId="layer-1"
onSelectLayer={() => {}}
onSelectElement={onSelectElement}
onAddLayer={() => {}}
onToggleLayer={() => {}}
/>,
);
expandLayer('Layer 1');
fireEvent.click(screen.getByText('Gruppe A'));
expect(onSelectElement).toHaveBeenCalledWith('elem-1');
});
});
// ─── PropertiesPanel ─────────────────────────────────