feat: add multi-select properties panel with bulk operations

Implements comprehensive multi-selection support for bulk operations on
actors, relations, and groups.

Features:
- New MultiSelectProperties panel for 2+ selected elements
- Bulk operations: group, ungroup, delete, minimize/maximize
- Add actors to existing groups (expands group bounds)
- Reverse relation directions
- Change directionality for multiple relations
- Immediate UI feedback with local state for directionality
- Standardized panel layout with scrollable content and footer

Fixes:
- Group positioning: actors stay at absolute positions when added/removed
- Minimize/maximize: properly stores/restores dimensions and visibility
- Position conversion between absolute and relative coordinates
- Type-safe width/height handling for group dimensions

UI Consistency:
- All property panels use fragment wrapper pattern
- Scrollable content area with px-3 py-3 padding
- Fixed footer section with action buttons
- Consistent button styles across panels

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-21 10:57:07 +02:00
parent 5bfd3029e1
commit 3b7497ec99
7 changed files with 842 additions and 61 deletions

View file

@ -52,3 +52,4 @@ Since this is a new project, the initial setup should include:
- Set up project structure (components, hooks, utils, types) - Set up project structure (components, hooks, utils, types)
- Configure linting and formatting tools - Configure linting and formatting tools
- Establish data models for nodes, edges, and graph state - Establish data models for nodes, edges, and graph state
- build: npm run build; lint: npm run lint

View file

@ -47,9 +47,15 @@ function AppContent() {
// Ref for LeftPanel to call focusSearch // Ref for LeftPanel to call focusSearch
const leftPanelRef = useRef<LeftPanelRef>(null); const leftPanelRef = useRef<LeftPanelRef>(null);
// Selection state - single item selection
const [selectedNode, setSelectedNode] = useState<Actor | null>(null); const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null); const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null); const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
// Multi-selection state
const [selectedActors, setSelectedActors] = useState<Actor[]>([]);
const [selectedRelations, setSelectedRelations] = useState<Relation[]>([]);
const [selectedGroups, setSelectedGroups] = useState<Group[]>([]);
// Use refs for callbacks to avoid triggering re-renders // Use refs for callbacks to avoid triggering re-renders
const addNodeCallbackRef = useRef< const addNodeCallbackRef = useRef<
((nodeTypeId: string, position?: { x: number; y: number }) => void) | null ((nodeTypeId: string, position?: { x: number; y: number }) => void) | null
@ -92,18 +98,35 @@ function AppContent() {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Escape: Close property panels // Escape: Close property panels
if (e.key === "Escape") { if (e.key === "Escape") {
if (selectedNode || selectedEdge || selectedGroup) { if (
selectedNode ||
selectedEdge ||
selectedGroup ||
selectedActors.length > 0 ||
selectedRelations.length > 0 ||
selectedGroups.length > 0
) {
e.preventDefault(); e.preventDefault();
setSelectedNode(null); setSelectedNode(null);
setSelectedEdge(null); setSelectedEdge(null);
setSelectedGroup(null); setSelectedGroup(null);
setSelectedActors([]);
setSelectedRelations([]);
setSelectedGroups([]);
} }
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNode, selectedEdge, selectedGroup]); }, [
selectedNode,
selectedEdge,
selectedGroup,
selectedActors,
selectedRelations,
selectedGroups,
]);
return ( return (
<div className="flex flex-col h-screen bg-gray-100"> <div className="flex flex-col h-screen bg-gray-100">
@ -146,6 +169,9 @@ function AppContent() {
setSelectedNode(null); setSelectedNode(null);
setSelectedEdge(null); setSelectedEdge(null);
setSelectedGroup(null); setSelectedGroup(null);
setSelectedActors([]);
setSelectedRelations([]);
setSelectedGroups([]);
}} }}
onAddNode={addNodeCallbackRef.current || undefined} onAddNode={addNodeCallbackRef.current || undefined}
/> />
@ -163,6 +189,9 @@ function AppContent() {
if (node) { if (node) {
setSelectedEdge(null); setSelectedEdge(null);
setSelectedGroup(null); setSelectedGroup(null);
setSelectedActors([]);
setSelectedRelations([]);
setSelectedGroups([]);
} }
}} }}
onEdgeSelect={(edge) => { onEdgeSelect={(edge) => {
@ -171,6 +200,9 @@ function AppContent() {
if (edge) { if (edge) {
setSelectedNode(null); setSelectedNode(null);
setSelectedGroup(null); setSelectedGroup(null);
setSelectedActors([]);
setSelectedRelations([]);
setSelectedGroups([]);
} }
}} }}
onGroupSelect={(group) => { onGroupSelect={(group) => {
@ -179,8 +211,20 @@ function AppContent() {
if (group) { if (group) {
setSelectedNode(null); setSelectedNode(null);
setSelectedEdge(null); setSelectedEdge(null);
setSelectedActors([]);
setSelectedRelations([]);
setSelectedGroups([]);
} }
}} }}
onMultiSelect={(actors, relations, groups) => {
setSelectedActors(actors);
setSelectedRelations(relations);
setSelectedGroups(groups);
// Clear single selections
setSelectedNode(null);
setSelectedEdge(null);
setSelectedGroup(null);
}}
onAddNodeRequest={( onAddNodeRequest={(
callback: ( callback: (
nodeTypeId: string, nodeTypeId: string,
@ -206,10 +250,16 @@ function AppContent() {
selectedNode={selectedNode} selectedNode={selectedNode}
selectedEdge={selectedEdge} selectedEdge={selectedEdge}
selectedGroup={selectedGroup} selectedGroup={selectedGroup}
selectedActors={selectedActors}
selectedRelations={selectedRelations}
selectedGroups={selectedGroups}
onClose={() => { onClose={() => {
setSelectedNode(null); setSelectedNode(null);
setSelectedEdge(null); setSelectedEdge(null);
setSelectedGroup(null); setSelectedGroup(null);
setSelectedActors([]);
setSelectedRelations([]);
setSelectedGroups([]);
}} }}
/> />
)} )}

View file

@ -54,6 +54,7 @@ interface GraphEditorProps {
onNodeSelect: (node: Actor | null) => void; onNodeSelect: (node: Actor | null) => void;
onEdgeSelect: (edge: Relation | null) => void; onEdgeSelect: (edge: Relation | null) => void;
onGroupSelect: (group: Group | null) => void; onGroupSelect: (group: Group | null) => void;
onMultiSelect?: (actors: Actor[], relations: Relation[], groups: Group[]) => void;
onAddNodeRequest?: (callback: (nodeTypeId: string, position?: { x: number; y: number }) => void) => void; onAddNodeRequest?: (callback: (nodeTypeId: string, position?: { x: number; y: number }) => void) => void;
onExportRequest?: (callback: (format: 'png' | 'svg', options?: ExportOptions) => Promise<void>) => void; onExportRequest?: (callback: (format: 'png' | 'svg', options?: ExportOptions) => Promise<void>) => void;
} }
@ -71,7 +72,7 @@ interface GraphEditorProps {
* *
* Usage: Core component that wraps React Flow with custom nodes and edges * Usage: Core component that wraps React Flow with custom nodes and edges
*/ */
const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeRequest, onExportRequest }: GraphEditorProps) => { const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect, onAddNodeRequest, onExportRequest }: GraphEditorProps) => {
// Sync with workspace active document // Sync with workspace active document
const { activeDocumentId } = useActiveDocument(); const { activeDocumentId } = useActiveDocument();
const { saveViewport, getViewport } = useWorkspaceStore(); const { saveViewport, getViewport } = useWorkspaceStore();
@ -470,8 +471,29 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque
nodes: Node[]; nodes: Node[];
edges: Edge[]; edges: Edge[];
}) => { }) => {
// If a single node is selected const totalSelected = selectedNodes.length + selectedEdges.length;
if (selectedNodes.length == 1) {
// Multi-selection: 2 or more items
if (totalSelected >= 2) {
const actors: Actor[] = [];
const groups: Group[] = [];
selectedNodes.forEach((node) => {
if (node.type === 'group') {
groups.push(node as Group);
} else {
actors.push(node as Actor);
}
});
const relations = selectedEdges as Relation[];
if (onMultiSelect) {
onMultiSelect(actors, relations, groups);
}
}
// Single node selected
else if (selectedNodes.length === 1) {
const selectedItem = selectedNodes[0]; const selectedItem = selectedNodes[0];
// Check if it's a group (type === 'group') // Check if it's a group (type === 'group')
@ -486,8 +508,8 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque
// Don't call others - parent will handle clearing // Don't call others - parent will handle clearing
} }
} }
// If an edge is selected, notify parent // Single edge selected
else if (selectedEdges.length == 1) { else if (selectedEdges.length === 1) {
const selectedEdge = selectedEdges[0] as Relation; const selectedEdge = selectedEdges[0] as Relation;
onEdgeSelect(selectedEdge); onEdgeSelect(selectedEdge);
// Don't call others - parent will handle clearing // Don't call others - parent will handle clearing
@ -499,7 +521,7 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque
onGroupSelect(null); onGroupSelect(null);
} }
}, },
[onNodeSelect, onEdgeSelect, onGroupSelect], [onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect],
); );
// Register the selection change handler with ReactFlow // Register the selection change handler with ReactFlow

View file

@ -154,7 +154,9 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => {
const groupActors = nodes.filter((node) => selectedGroup.data.actorIds.includes(node.id)); const groupActors = nodes.filter((node) => selectedGroup.data.actorIds.includes(node.id));
return ( return (
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
{/* Name */} {/* Name */}
<div> <div>
<label className="block text-xs font-medium text-gray-600 mb-1"> <label className="block text-xs font-medium text-gray-600 mb-1">
@ -242,9 +244,10 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => {
</div> </div>
)} )}
</div> </div>
</div>
{/* Actions */} {/* Footer with actions */}
<div className="pt-4 border-t border-gray-200 space-y-2"> <div className="px-3 py-3 border-t border-gray-200 bg-gray-50 space-y-2">
<button <button
onClick={() => { onClick={() => {
// Sync current React Flow dimensions before toggling // Sync current React Flow dimensions before toggling
@ -264,7 +267,7 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => {
toggleGroupMinimized(currentGroup.id); toggleGroupMinimized(currentGroup.id);
}, 0); }, 0);
}} }}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-gray-50 rounded hover:bg-gray-100 transition-colors flex items-center justify-center space-x-2" className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
> >
{currentGroup.data.minimized ? ( {currentGroup.data.minimized ? (
<> <>
@ -280,13 +283,13 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => {
</button> </button>
<button <button
onClick={handleUngroup} onClick={handleUngroup}
className="w-full px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded hover:bg-blue-100 transition-colors" className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
> >
Ungroup (Keep Actors) <span>Ungroup (Keep Actors)</span>
</button> </button>
<button <button
onClick={handleDeleteGroup} onClick={handleDeleteGroup}
className="w-full px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded hover:bg-red-100 transition-colors flex items-center justify-center space-x-2" className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors"
> >
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
<span>Delete Group & Actors</span> <span>Delete Group & Actors</span>
@ -294,7 +297,7 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => {
</div> </div>
{ConfirmDialogComponent} {ConfirmDialogComponent}
</div> </>
); );
}; };

View file

@ -0,0 +1,587 @@
import { useState, useMemo } from 'react';
import { Chip, ToggleButton, ToggleButtonGroup, Tooltip, IconButton } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import GroupWorkIcon from '@mui/icons-material/GroupWork';
import UngroupIcon from '@mui/icons-material/CallSplit';
import MinimizeIcon from '@mui/icons-material/Minimize';
import MaximizeIcon from '@mui/icons-material/Maximize';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import SyncAltIcon from '@mui/icons-material/SyncAlt';
import RemoveIcon from '@mui/icons-material/Remove';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import GroupAddIcon from '@mui/icons-material/GroupAdd';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useDocumentHistory } from '../../hooks/useDocumentHistory';
import { useConfirm } from '../../hooks/useConfirm';
import type { Actor, Relation, Group } from '../../types';
/**
* MultiSelectProperties - Panel shown when multiple elements are selected
*
* Features:
* - Selection statistics and summary
* - Bulk operations contextual to selection type
* - Reuses logic from context menu operations
* - Confirmation for destructive actions
*/
interface Props {
selectedActors: Actor[];
selectedRelations: Relation[];
selectedGroups: Group[];
onClose: () => void;
}
interface SelectionStats {
actorCount: number;
relationCount: number;
groupCount: number;
actorTypeBreakdown: Map<string, number>;
relationTypeBreakdown: Map<string, number>;
totalElements: number;
}
const MultiSelectProperties = ({
selectedActors,
selectedRelations,
selectedGroups,
onClose,
}: Props) => {
const {
deleteNode,
deleteEdge,
deleteGroup,
createGroupWithActors,
addActorToGroup,
updateGroup,
updateEdge,
setEdges,
edges,
nodeTypes,
edgeTypes,
} = useGraphWithHistory();
const { pushToHistory } = useDocumentHistory();
const { confirm, ConfirmDialogComponent } = useConfirm();
const [processing, setProcessing] = useState(false);
// Local state for directionality to enable immediate UI updates
const [currentDirectionality, setCurrentDirectionality] = useState<
'directed' | 'bidirectional' | 'undirected' | null
>(null);
// Calculate selection statistics
const stats: SelectionStats = useMemo(() => {
const actorTypeBreakdown = new Map<string, number>();
selectedActors.forEach((actor) => {
const count = actorTypeBreakdown.get(actor.data.type) || 0;
actorTypeBreakdown.set(actor.data.type, count + 1);
});
const relationTypeBreakdown = new Map<string, number>();
selectedRelations.forEach((relation) => {
if (relation.data) {
const count = relationTypeBreakdown.get(relation.data.type) || 0;
relationTypeBreakdown.set(relation.data.type, count + 1);
}
});
return {
actorCount: selectedActors.length,
relationCount: selectedRelations.length,
groupCount: selectedGroups.length,
actorTypeBreakdown,
relationTypeBreakdown,
totalElements:
selectedActors.length + selectedRelations.length + selectedGroups.length,
};
}, [selectedActors, selectedRelations, selectedGroups]);
// Determine what operations are available
const canGroupActors = selectedActors.length >= 2;
const canDeleteSelection = stats.totalElements > 0;
const canUngroupAll = selectedGroups.length > 0;
const canMinimizeAll =
selectedGroups.length > 0 &&
selectedGroups.some((g) => !g.data.minimized);
const canMaximizeAll =
selectedGroups.length > 0 &&
selectedGroups.some((g) => g.data.minimized);
// Check if we can add actors to a group (1 group + 1+ actors selected)
const canAddToGroup = selectedGroups.length === 1 && selectedActors.length >= 1;
const targetGroup = selectedGroups.length === 1 ? selectedGroups[0] : null;
// Check if all selected relations have the same directionality
const allSameDirectionality = useMemo(() => {
if (selectedRelations.length === 0) return null;
const first = selectedRelations[0].data?.directionality;
if (!first) return null;
const sameDirectionality = selectedRelations.every((r) => r.data?.directionality === first)
? first
: null;
// Initialize local state when selection changes
setCurrentDirectionality(sameDirectionality);
return sameDirectionality;
}, [selectedRelations]);
// Handlers
const handleAddToGroup = () => {
if (!targetGroup) return;
setProcessing(true);
try {
selectedActors.forEach((actor) => {
addActorToGroup(actor.id, targetGroup.id);
});
onClose();
} finally {
setProcessing(false);
}
};
const handleGroupActors = () => {
setProcessing(true);
try {
// Calculate bounding box for selected actors
const positions = selectedActors.map((a) => a.position);
const minX = Math.min(...positions.map((p) => p.x));
const minY = Math.min(...positions.map((p) => p.y));
const maxX = Math.max(...positions.map((p) => p.x + 150)); // Assume node width ~150
const maxY = Math.max(...positions.map((p) => p.y + 80)); // Assume node height ~80
const groupId = `group-${Date.now()}`;
const groupPosition = { x: minX - 20, y: minY - 40 };
const groupWidth = maxX - minX + 40;
const groupHeight = maxY - minY + 60;
// Create group node
const newGroup: Group = {
id: groupId,
type: 'group',
position: groupPosition,
data: {
label: 'New Group',
description: '',
color: 'rgba(240, 242, 245, 0.5)',
actorIds: selectedActors.map((a) => a.id),
},
style: {
width: groupWidth,
height: groupHeight,
},
};
// Build actor updates map (relative positions and parent relationship)
const actorUpdates: Record<
string,
{ position: { x: number; y: number }; parentId: string; extent: 'parent' }
> = {};
selectedActors.forEach((actor) => {
actorUpdates[actor.id] = {
position: {
x: actor.position.x - groupPosition.x,
y: actor.position.y - groupPosition.y,
},
parentId: groupId,
extent: 'parent' as const,
};
});
createGroupWithActors(newGroup, selectedActors.map((a) => a.id), actorUpdates);
onClose();
} finally {
setProcessing(false);
}
};
const handleDeleteSelection = async () => {
const confirmed = await confirm({
title: 'Delete Selection',
message: `Delete ${stats.totalElements} selected item(s)? This action cannot be undone.`,
confirmLabel: 'Delete',
severity: 'danger',
});
if (!confirmed) return;
setProcessing(true);
try {
// Delete in order: edges, nodes, groups (keep group actors - they may not be selected)
selectedRelations.forEach((rel) => deleteEdge(rel.id));
selectedActors.forEach((actor) => deleteNode(actor.id));
selectedGroups.forEach((group) => deleteGroup(group.id, true)); // true = ungroup (keep actors)
} finally {
setProcessing(false);
onClose();
}
};
const handleUngroupAll = async () => {
const confirmed = await confirm({
title: 'Ungroup All',
message: `Ungroup ${selectedGroups.length} group(s)? Actors will be preserved.`,
confirmLabel: 'Ungroup',
severity: 'warning',
});
if (!confirmed) return;
setProcessing(true);
try {
// ungroupActors=true means keep actors (ungroup)
selectedGroups.forEach((group) => deleteGroup(group.id, true));
onClose();
} finally {
setProcessing(false);
}
};
const handleDeleteGroupsAndActors = async () => {
const totalActors = selectedGroups.reduce(
(sum, g) => sum + g.data.actorIds.length,
0,
);
const confirmed = await confirm({
title: 'Delete Groups & Actors',
message: `Delete ${selectedGroups.length} group(s) and ${totalActors} actor(s)? This action cannot be undone.`,
confirmLabel: 'Delete All',
severity: 'danger',
});
if (!confirmed) return;
setProcessing(true);
try {
// ungroupActors=false means delete actors with group
selectedGroups.forEach((group) => deleteGroup(group.id, false));
onClose();
} finally {
setProcessing(false);
}
};
const handleMinimizeAll = () => {
setProcessing(true);
try {
selectedGroups.forEach((group) => {
if (!group.data.minimized) {
updateGroup(group.id, { minimized: true });
}
});
} finally {
setProcessing(false);
}
};
const handleMaximizeAll = () => {
setProcessing(true);
try {
selectedGroups.forEach((group) => {
if (group.data.minimized) {
updateGroup(group.id, { minimized: false });
}
});
} finally {
setProcessing(false);
}
};
const handleToggleDirectionality = (
newDirectionality: 'directed' | 'bidirectional' | 'undirected',
) => {
// Update local state immediately for instant UI feedback
setCurrentDirectionality(newDirectionality);
setProcessing(true);
try {
selectedRelations.forEach((rel) => {
updateEdge(rel.id, {
...rel.data,
directionality: newDirectionality,
});
});
} finally {
setProcessing(false);
}
};
const handleReverseDirections = () => {
setProcessing(true);
try {
// Push to history BEFORE mutation
pushToHistory(
`Reverse Direction: ${selectedRelations.length} relation${selectedRelations.length > 1 ? 's' : ''}`,
);
// Create a Set of IDs to reverse for efficient lookup
const idsToReverse = new Set(selectedRelations.map((r) => r.id));
// Update the edges array with reversed edges
const updatedEdges = edges.map((edge) => {
if (idsToReverse.has(edge.id)) {
return {
...edge,
source: edge.target,
target: edge.source,
sourceHandle: edge.targetHandle,
targetHandle: edge.sourceHandle,
};
}
return edge;
});
// Apply the update (setEdges is a pass-through without history tracking)
setEdges(updatedEdges);
} finally {
setProcessing(false);
}
};
return (
<>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
{/* Selection Summary */}
<div className="space-y-2">
<h3 className="text-xs font-semibold text-gray-600 uppercase tracking-wide">
Selection Summary
</h3>
<div className="flex flex-wrap gap-2">
{stats.actorCount > 0 && (
<Chip
label={`${stats.actorCount} Actor${stats.actorCount > 1 ? 's' : ''}`}
size="small"
color="primary"
variant="outlined"
/>
)}
{stats.relationCount > 0 && (
<Chip
label={`${stats.relationCount} Relation${stats.relationCount > 1 ? 's' : ''}`}
size="small"
color="secondary"
variant="outlined"
/>
)}
{stats.groupCount > 0 && (
<Chip
label={`${stats.groupCount} Group${stats.groupCount > 1 ? 's' : ''}`}
size="small"
color="info"
variant="outlined"
/>
)}
</div>
{/* Type breakdown for actors */}
{stats.actorTypeBreakdown.size > 0 && (
<div className="text-xs text-gray-600 space-y-1">
<div className="font-medium">Actor Types:</div>
{Array.from(stats.actorTypeBreakdown.entries()).map(([typeId, count]) => {
const nodeType = nodeTypes.find((t) => t.id === typeId);
return (
<div key={typeId} className="flex items-center gap-2 ml-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: nodeType?.color || '#999' }}
/>
<span>
{nodeType?.label || typeId}: {count}
</span>
</div>
);
})}
</div>
)}
{/* Type breakdown for relations */}
{stats.relationTypeBreakdown.size > 0 && (
<div className="text-xs text-gray-600 space-y-1">
<div className="font-medium">Relation Types:</div>
{Array.from(stats.relationTypeBreakdown.entries()).map(
([typeId, count]) => {
const edgeType = edgeTypes.find((t) => t.id === typeId);
return (
<div key={typeId} className="flex items-center gap-2 ml-2">
<div
className="w-8 h-0.5"
style={{
backgroundColor: edgeType?.color || '#999',
borderStyle: edgeType?.style || 'solid',
}}
/>
<span>
{edgeType?.label || typeId}: {count}
</span>
</div>
);
},
)}
</div>
)}
</div>
</div>
{/* Footer with actions */}
<div className="px-3 py-3 border-t border-gray-200 bg-gray-50 space-y-2">
{/* Actor-specific actions */}
{canAddToGroup && targetGroup && (
<button
onClick={handleAddToGroup}
disabled={processing}
className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<GroupAddIcon fontSize="small" />
<span>
Add {selectedActors.length} Actor{selectedActors.length > 1 ? 's' : ''} to &quot;
{targetGroup.data.label}&quot;
</span>
</button>
)}
{canGroupActors && (
<button
onClick={handleGroupActors}
disabled={processing}
className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<GroupWorkIcon fontSize="small" />
<span>Group Selected Actors</span>
</button>
)}
{/* Group-specific actions */}
{selectedGroups.length > 0 && (
<>
{canMinimizeAll && (
<button
onClick={handleMinimizeAll}
disabled={processing}
className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<MinimizeIcon fontSize="small" />
<span>Minimize All Groups</span>
</button>
)}
{canMaximizeAll && (
<button
onClick={handleMaximizeAll}
disabled={processing}
className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<MaximizeIcon fontSize="small" />
<span>Maximize All Groups</span>
</button>
)}
{canUngroupAll && (
<button
onClick={handleUngroupAll}
disabled={processing}
className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-orange-700 bg-white border border-orange-300 hover:bg-orange-50 rounded focus:outline-none focus:ring-2 focus:ring-orange-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<UngroupIcon fontSize="small" />
<span>Ungroup All (Keep Actors)</span>
</button>
)}
<button
onClick={handleDeleteGroupsAndActors}
disabled={processing}
className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-red-700 bg-white border border-red-300 hover:bg-red-50 rounded focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<DeleteIcon fontSize="small" />
<span>Delete Groups & Actors</span>
</button>
</>
)}
{/* Relation-specific actions */}
{selectedRelations.length > 0 && (
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-medium text-gray-700">
Directionality
</label>
<Tooltip title="Reverse Direction (swap source/target)">
<IconButton
size="small"
onClick={handleReverseDirections}
disabled={processing}
>
<SwapHorizIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
<ToggleButtonGroup
value={currentDirectionality}
exclusive
onChange={(_, newValue) => {
if (newValue !== null) {
handleToggleDirectionality(newValue);
}
}}
fullWidth
size="small"
disabled={processing}
aria-label="relationship directionality"
>
<ToggleButton value="directed" aria-label="directed relationship">
<Tooltip title="Directed (one-way)">
<div className="flex items-center space-x-1">
<ArrowForwardIcon fontSize="small" />
<span className="text-xs">Directed</span>
</div>
</Tooltip>
</ToggleButton>
<ToggleButton value="bidirectional" aria-label="bidirectional relationship">
<Tooltip title="Bidirectional (two-way)">
<div className="flex items-center space-x-1">
<SyncAltIcon fontSize="small" />
<span className="text-xs">Bi</span>
</div>
</Tooltip>
</ToggleButton>
<ToggleButton value="undirected" aria-label="undirected relationship">
<Tooltip title="Undirected (no direction)">
<div className="flex items-center space-x-1">
<RemoveIcon fontSize="small" />
<span className="text-xs">None</span>
</div>
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
{!allSameDirectionality && (
<p className="text-xs text-gray-500 mt-1 italic">
Selected relations have different directionalities
</p>
)}
</div>
</div>
)}
{/* General delete action */}
{canDeleteSelection && (
<button
onClick={handleDeleteSelection}
disabled={processing}
className="w-full flex items-center justify-center space-x-2 px-3 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<DeleteIcon fontSize="small" />
<span>Delete Selection ({stats.totalElements})</span>
</button>
)}
</div>
{ConfirmDialogComponent}
</>
);
};
export default MultiSelectProperties;

View file

@ -7,6 +7,7 @@ import NodeEditorPanel from './NodeEditorPanel';
import EdgeEditorPanel from './EdgeEditorPanel'; import EdgeEditorPanel from './EdgeEditorPanel';
import GroupEditorPanel from './GroupEditorPanel'; import GroupEditorPanel from './GroupEditorPanel';
import GraphAnalysisPanel from './GraphAnalysisPanel'; import GraphAnalysisPanel from './GraphAnalysisPanel';
import MultiSelectProperties from './MultiSelectProperties';
import type { Actor, Relation, Group } from '../../types'; import type { Actor, Relation, Group } from '../../types';
/** /**
@ -25,6 +26,9 @@ interface Props {
selectedNode: Actor | null; selectedNode: Actor | null;
selectedEdge: Relation | null; selectedEdge: Relation | null;
selectedGroup: Group | null; selectedGroup: Group | null;
selectedActors?: Actor[];
selectedRelations?: Relation[];
selectedGroups?: Group[];
onClose: () => void; onClose: () => void;
} }
@ -47,7 +51,15 @@ const PanelHeader = ({ title, onCollapse }: PanelHeaderProps) => (
</div> </div>
); );
const RightPanel = ({ selectedNode, selectedEdge, selectedGroup, onClose }: Props) => { const RightPanel = ({
selectedNode,
selectedEdge,
selectedGroup,
selectedActors = [],
selectedRelations = [],
selectedGroups = [],
onClose,
}: Props) => {
const { const {
rightPanelCollapsed, rightPanelCollapsed,
rightPanelWidth, rightPanelWidth,
@ -57,6 +69,11 @@ const RightPanel = ({ selectedNode, selectedEdge, selectedGroup, onClose }: Prop
const { nodes, edges } = useGraphWithHistory(); const { nodes, edges } = useGraphWithHistory();
// Calculate total multi-selection count
const totalMultiSelect =
selectedActors.length + selectedRelations.length + selectedGroups.length;
const hasMultiSelect = totalMultiSelect >= 2;
// Collapsed view // Collapsed view
if (rightPanelCollapsed) { if (rightPanelCollapsed) {
return ( return (
@ -70,6 +87,24 @@ const RightPanel = ({ selectedNode, selectedEdge, selectedGroup, onClose }: Prop
); );
} }
// Multi-select view (priority over single selections)
if (hasMultiSelect) {
return (
<div
className="h-full bg-white border-l border-gray-200 flex flex-col"
style={{ width: `${rightPanelWidth}px` }}
>
<PanelHeader title="Multi-Select Properties" onCollapse={collapseRightPanel} />
<MultiSelectProperties
selectedActors={selectedActors}
selectedRelations={selectedRelations}
selectedGroups={selectedGroups}
onClose={onClose}
/>
</div>
);
}
// Group properties view (priority over node/edge if group selected) // Group properties view (priority over node/edge if group selected)
if (selectedGroup) { if (selectedGroup) {
return ( return (

View file

@ -241,13 +241,25 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
deleteGroup: (id: string, ungroupActors = true) => deleteGroup: (id: string, ungroupActors = true) =>
set((state) => { set((state) => {
if (ungroupActors) { if (ungroupActors) {
// Remove group and unparent actors (move them back to canvas) // Remove group and unparent actors (keep them at their current absolute positions)
// Note: parentId is a React Flow v11+ property for parent-child relationships // Note: parentId is a React Flow v11+ property for parent-child relationships
const group = state.groups.find((g) => g.id === id);
const updatedNodes = state.nodes.map((node) => { const updatedNodes = state.nodes.map((node) => {
const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' }; const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' };
return nodeWithParent.parentId === id if (nodeWithParent.parentId === id && group) {
? { ...node, parentId: undefined, extent: undefined } // Convert relative position to absolute position
: node; return {
...node,
parentId: undefined,
extent: undefined,
position: {
x: group.position.x + node.position.x,
y: group.position.y + node.position.y,
}
};
}
return node;
}); });
return { return {
@ -278,26 +290,77 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
addActorToGroup: (actorId: string, groupId: string) => addActorToGroup: (actorId: string, groupId: string) =>
set((state) => { set((state) => {
const group = state.groups.find((g) => g.id === groupId); const group = state.groups.find((g) => g.id === groupId);
if (!group) return state; const actor = state.nodes.find((n) => n.id === actorId);
if (!group || !actor) return state;
// Update actor to be child of group // Calculate new group bounds to include the actor
const updatedNodes = state.nodes.map((node) => const actorWidth = 150; // Approximate node width
node.id === actorId const actorHeight = 80; // Approximate node height
? { const padding = 20;
const actorAbsX = actor.position.x;
const actorAbsY = actor.position.y;
// Current group bounds
const groupX = group.position.x;
const groupY = group.position.y;
const groupWidth = typeof group.style?.width === 'number' ? group.style.width : 200;
const groupHeight = typeof group.style?.height === 'number' ? group.style.height : 200;
// Calculate new bounds
const newMinX = Math.min(groupX, actorAbsX - padding);
const newMinY = Math.min(groupY, actorAbsY - padding);
const newMaxX = Math.max(groupX + groupWidth, actorAbsX + actorWidth + padding);
const newMaxY = Math.max(groupY + groupHeight, actorAbsY + actorHeight + padding);
const newGroupX = newMinX;
const newGroupY = newMinY;
const newGroupWidth = newMaxX - newMinX;
const newGroupHeight = newMaxY - newMinY;
// Calculate position delta for existing child nodes
const deltaX = groupX - newGroupX;
const deltaY = groupY - newGroupY;
// Update actor to be child of group with relative position
const updatedNodes = state.nodes.map((node) => {
const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' };
if (node.id === actorId) {
// New actor: convert to relative position
return {
...node, ...node,
parentId: groupId, parentId: groupId,
extent: 'parent' as const, extent: 'parent' as const,
// Convert to relative position (will be adjusted in component) position: {
position: node.position, x: actorAbsX - newGroupX,
y: actorAbsY - newGroupY,
},
};
} else if (nodeWithParent.parentId === groupId) {
// Existing child: adjust position due to group position change
return {
...node,
position: {
x: node.position.x + deltaX,
y: node.position.y + deltaY,
},
};
} }
: node return node;
); });
// Update group's actorIds // Update group's position, size, and actorIds
const updatedGroups = state.groups.map((g) => const updatedGroups = state.groups.map((g) =>
g.id === groupId g.id === groupId
? { ? {
...g, ...g,
position: { x: newGroupX, y: newGroupY },
style: {
...g.style,
width: newGroupWidth,
height: newGroupHeight,
},
data: { data: {
...g.data, ...g.data,
actorIds: [...g.data.actorIds, actorId], actorIds: [...g.data.actorIds, actorId],
@ -314,17 +377,23 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
removeActorFromGroup: (actorId: string, groupId: string) => removeActorFromGroup: (actorId: string, groupId: string) =>
set((state) => { set((state) => {
// Update actor to remove parent const group = state.groups.find((g) => g.id === groupId);
const updatedNodes = state.nodes.map((node) =>
node.id === actorId // Update actor to remove parent and convert to absolute position
? { const updatedNodes = state.nodes.map((node) => {
if (node.id === actorId && group) {
return {
...node, ...node,
parentId: undefined, parentId: undefined,
extent: undefined, extent: undefined,
// Keep current position (will be adjusted in component) position: {
x: group.position.x + node.position.x,
y: group.position.y + node.position.y,
},
};
} }
: node return node;
); });
// Update group's actorIds // Update group's actorIds
const updatedGroups = state.groups.map((g) => const updatedGroups = state.groups.map((g) =>
@ -352,12 +421,15 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
const isMinimized = !group.data.minimized; const isMinimized = !group.data.minimized;
// Update group's minimized state // Update group's minimized state and child nodes
const updatedGroups = state.groups.map((g) => { const updatedGroups = state.groups.map((g) => {
if (g.id !== groupId) return g; if (g.id !== groupId) return g;
if (isMinimized) { if (isMinimized) {
// Minimizing: store original dimensions in metadata // Minimizing: store original dimensions in metadata
const currentWidth = typeof g.style?.width === 'number' ? g.style.width : 300;
const currentHeight = typeof g.style?.height === 'number' ? g.style.height : 200;
return { return {
...g, ...g,
data: { data: {
@ -365,17 +437,14 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
minimized: true, minimized: true,
metadata: { metadata: {
...g.data.metadata, ...g.data.metadata,
originalWidth: g.width, originalWidth: currentWidth,
originalHeight: g.height, originalHeight: currentHeight,
}, },
}, },
style: {
...g.style,
width: 220, width: 220,
height: 80, height: 80,
// Override wrapper styles to remove padding and border
style: {
padding: 0,
border: 'none',
backgroundColor: 'white', // Solid background (inner div will cover with its own color)
}, },
}; };
} else { } else {
@ -394,16 +463,30 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
minimized: false, minimized: false,
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined, metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined,
}, },
style: {
...g.style,
width: originalWidth, width: originalWidth,
height: originalHeight, height: originalHeight,
// Remove wrapper style overrides for maximized state },
style: undefined,
}; };
} }
}); });
// When minimizing, hide child nodes; when maximizing, show them
const updatedNodes = state.nodes.map((node) => {
const nodeWithParent = node as Actor & { parentId?: string };
if (nodeWithParent.parentId === groupId) {
return {
...node,
hidden: isMinimized,
};
}
return node;
});
return { return {
groups: updatedGroups, groups: updatedGroups,
nodes: updatedNodes,
}; };
}), }),