mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
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:
parent
5bfd3029e1
commit
3b7497ec99
7 changed files with 842 additions and 61 deletions
|
|
@ -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
|
||||||
54
src/App.tsx
54
src/App.tsx
|
|
@ -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([]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -154,9 +154,11 @@ 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">
|
<>
|
||||||
{/* Name */}
|
{/* Scrollable content */}
|
||||||
<div>
|
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<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">
|
||||||
Group Name
|
Group Name
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
587
src/components/Panels/MultiSelectProperties.tsx
Normal file
587
src/components/Panels/MultiSelectProperties.tsx
Normal 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 "
|
||||||
|
{targetGroup.data.label}"
|
||||||
|
</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;
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
...node,
|
|
||||||
parentId: groupId,
|
|
||||||
extent: 'parent' as const,
|
|
||||||
// Convert to relative position (will be adjusted in component)
|
|
||||||
position: node.position,
|
|
||||||
}
|
|
||||||
: node
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update group's actorIds
|
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,
|
||||||
|
parentId: groupId,
|
||||||
|
extent: 'parent' as const,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
...node,
|
if (node.id === actorId && group) {
|
||||||
parentId: undefined,
|
return {
|
||||||
extent: undefined,
|
...node,
|
||||||
// Keep current position (will be adjusted in component)
|
parentId: undefined,
|
||||||
}
|
extent: undefined,
|
||||||
: node
|
position: {
|
||||||
);
|
x: group.position.x + node.position.x,
|
||||||
|
y: group.position.y + node.position.y,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
width: 220,
|
|
||||||
height: 80,
|
|
||||||
// Override wrapper styles to remove padding and border
|
|
||||||
style: {
|
style: {
|
||||||
padding: 0,
|
...g.style,
|
||||||
border: 'none',
|
width: 220,
|
||||||
backgroundColor: 'white', // Solid background (inner div will cover with its own color)
|
height: 80,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} 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,
|
||||||
},
|
},
|
||||||
width: originalWidth,
|
style: {
|
||||||
height: originalHeight,
|
...g.style,
|
||||||
// Remove wrapper style overrides for maximized state
|
width: originalWidth,
|
||||||
style: undefined,
|
height: originalHeight,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue