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)
|
||||
- Configure linting and formatting tools
|
||||
- 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
|
||||
const leftPanelRef = useRef<LeftPanelRef>(null);
|
||||
// Selection state - single item selection
|
||||
const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
|
||||
const [selectedEdge, setSelectedEdge] = useState<Relation | 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
|
||||
const addNodeCallbackRef = useRef<
|
||||
((nodeTypeId: string, position?: { x: number; y: number }) => void) | null
|
||||
|
|
@ -92,18 +98,35 @@ function AppContent() {
|
|||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Escape: Close property panels
|
||||
if (e.key === "Escape") {
|
||||
if (selectedNode || selectedEdge || selectedGroup) {
|
||||
if (
|
||||
selectedNode ||
|
||||
selectedEdge ||
|
||||
selectedGroup ||
|
||||
selectedActors.length > 0 ||
|
||||
selectedRelations.length > 0 ||
|
||||
selectedGroups.length > 0
|
||||
) {
|
||||
e.preventDefault();
|
||||
setSelectedNode(null);
|
||||
setSelectedEdge(null);
|
||||
setSelectedGroup(null);
|
||||
setSelectedActors([]);
|
||||
setSelectedRelations([]);
|
||||
setSelectedGroups([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedNode, selectedEdge, selectedGroup]);
|
||||
}, [
|
||||
selectedNode,
|
||||
selectedEdge,
|
||||
selectedGroup,
|
||||
selectedActors,
|
||||
selectedRelations,
|
||||
selectedGroups,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-100">
|
||||
|
|
@ -146,6 +169,9 @@ function AppContent() {
|
|||
setSelectedNode(null);
|
||||
setSelectedEdge(null);
|
||||
setSelectedGroup(null);
|
||||
setSelectedActors([]);
|
||||
setSelectedRelations([]);
|
||||
setSelectedGroups([]);
|
||||
}}
|
||||
onAddNode={addNodeCallbackRef.current || undefined}
|
||||
/>
|
||||
|
|
@ -163,6 +189,9 @@ function AppContent() {
|
|||
if (node) {
|
||||
setSelectedEdge(null);
|
||||
setSelectedGroup(null);
|
||||
setSelectedActors([]);
|
||||
setSelectedRelations([]);
|
||||
setSelectedGroups([]);
|
||||
}
|
||||
}}
|
||||
onEdgeSelect={(edge) => {
|
||||
|
|
@ -171,6 +200,9 @@ function AppContent() {
|
|||
if (edge) {
|
||||
setSelectedNode(null);
|
||||
setSelectedGroup(null);
|
||||
setSelectedActors([]);
|
||||
setSelectedRelations([]);
|
||||
setSelectedGroups([]);
|
||||
}
|
||||
}}
|
||||
onGroupSelect={(group) => {
|
||||
|
|
@ -179,8 +211,20 @@ function AppContent() {
|
|||
if (group) {
|
||||
setSelectedNode(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={(
|
||||
callback: (
|
||||
nodeTypeId: string,
|
||||
|
|
@ -206,10 +250,16 @@ function AppContent() {
|
|||
selectedNode={selectedNode}
|
||||
selectedEdge={selectedEdge}
|
||||
selectedGroup={selectedGroup}
|
||||
selectedActors={selectedActors}
|
||||
selectedRelations={selectedRelations}
|
||||
selectedGroups={selectedGroups}
|
||||
onClose={() => {
|
||||
setSelectedNode(null);
|
||||
setSelectedEdge(null);
|
||||
setSelectedGroup(null);
|
||||
setSelectedActors([]);
|
||||
setSelectedRelations([]);
|
||||
setSelectedGroups([]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ interface GraphEditorProps {
|
|||
onNodeSelect: (node: Actor | null) => void;
|
||||
onEdgeSelect: (edge: Relation | 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;
|
||||
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
|
||||
*/
|
||||
const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeRequest, onExportRequest }: GraphEditorProps) => {
|
||||
const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect, onAddNodeRequest, onExportRequest }: GraphEditorProps) => {
|
||||
// Sync with workspace active document
|
||||
const { activeDocumentId } = useActiveDocument();
|
||||
const { saveViewport, getViewport } = useWorkspaceStore();
|
||||
|
|
@ -470,8 +471,29 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque
|
|||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
}) => {
|
||||
// If a single node is selected
|
||||
if (selectedNodes.length == 1) {
|
||||
const totalSelected = selectedNodes.length + selectedEdges.length;
|
||||
|
||||
// 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];
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// If an edge is selected, notify parent
|
||||
else if (selectedEdges.length == 1) {
|
||||
// Single edge selected
|
||||
else if (selectedEdges.length === 1) {
|
||||
const selectedEdge = selectedEdges[0] as Relation;
|
||||
onEdgeSelect(selectedEdge);
|
||||
// Don't call others - parent will handle clearing
|
||||
|
|
@ -499,7 +521,7 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque
|
|||
onGroupSelect(null);
|
||||
}
|
||||
},
|
||||
[onNodeSelect, onEdgeSelect, onGroupSelect],
|
||||
[onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect],
|
||||
);
|
||||
|
||||
// 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));
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<>
|
||||
{/* Scrollable content */}
|
||||
<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">
|
||||
Group Name
|
||||
</label>
|
||||
|
|
@ -242,9 +244,10 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-4 border-t border-gray-200 space-y-2">
|
||||
{/* Footer with actions */}
|
||||
<div className="px-3 py-3 border-t border-gray-200 bg-gray-50 space-y-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Sync current React Flow dimensions before toggling
|
||||
|
|
@ -264,7 +267,7 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => {
|
|||
toggleGroupMinimized(currentGroup.id);
|
||||
}, 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 ? (
|
||||
<>
|
||||
|
|
@ -280,13 +283,13 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => {
|
|||
</button>
|
||||
<button
|
||||
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
|
||||
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" />
|
||||
<span>Delete Group & Actors</span>
|
||||
|
|
@ -294,7 +297,7 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => {
|
|||
</div>
|
||||
|
||||
{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 GroupEditorPanel from './GroupEditorPanel';
|
||||
import GraphAnalysisPanel from './GraphAnalysisPanel';
|
||||
import MultiSelectProperties from './MultiSelectProperties';
|
||||
import type { Actor, Relation, Group } from '../../types';
|
||||
|
||||
/**
|
||||
|
|
@ -25,6 +26,9 @@ interface Props {
|
|||
selectedNode: Actor | null;
|
||||
selectedEdge: Relation | null;
|
||||
selectedGroup: Group | null;
|
||||
selectedActors?: Actor[];
|
||||
selectedRelations?: Relation[];
|
||||
selectedGroups?: Group[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +51,15 @@ const PanelHeader = ({ title, onCollapse }: PanelHeaderProps) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const RightPanel = ({ selectedNode, selectedEdge, selectedGroup, onClose }: Props) => {
|
||||
const RightPanel = ({
|
||||
selectedNode,
|
||||
selectedEdge,
|
||||
selectedGroup,
|
||||
selectedActors = [],
|
||||
selectedRelations = [],
|
||||
selectedGroups = [],
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const {
|
||||
rightPanelCollapsed,
|
||||
rightPanelWidth,
|
||||
|
|
@ -57,6 +69,11 @@ const RightPanel = ({ selectedNode, selectedEdge, selectedGroup, onClose }: Prop
|
|||
|
||||
const { nodes, edges } = useGraphWithHistory();
|
||||
|
||||
// Calculate total multi-selection count
|
||||
const totalMultiSelect =
|
||||
selectedActors.length + selectedRelations.length + selectedGroups.length;
|
||||
const hasMultiSelect = totalMultiSelect >= 2;
|
||||
|
||||
// Collapsed view
|
||||
if (rightPanelCollapsed) {
|
||||
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)
|
||||
if (selectedGroup) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -241,13 +241,25 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
deleteGroup: (id: string, ungroupActors = true) =>
|
||||
set((state) => {
|
||||
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
|
||||
const group = state.groups.find((g) => g.id === id);
|
||||
|
||||
const updatedNodes = state.nodes.map((node) => {
|
||||
const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' };
|
||||
return nodeWithParent.parentId === id
|
||||
? { ...node, parentId: undefined, extent: undefined }
|
||||
: node;
|
||||
if (nodeWithParent.parentId === id && group) {
|
||||
// Convert relative position to absolute position
|
||||
return {
|
||||
...node,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
position: {
|
||||
x: group.position.x + node.position.x,
|
||||
y: group.position.y + node.position.y,
|
||||
}
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -278,26 +290,77 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
addActorToGroup: (actorId: string, groupId: string) =>
|
||||
set((state) => {
|
||||
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
|
||||
const updatedNodes = state.nodes.map((node) =>
|
||||
node.id === actorId
|
||||
? {
|
||||
...node,
|
||||
parentId: groupId,
|
||||
extent: 'parent' as const,
|
||||
// Convert to relative position (will be adjusted in component)
|
||||
position: node.position,
|
||||
}
|
||||
: node
|
||||
);
|
||||
// Calculate new group bounds to include the actor
|
||||
const actorWidth = 150; // Approximate node width
|
||||
const actorHeight = 80; // Approximate node height
|
||||
const padding = 20;
|
||||
|
||||
// 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) =>
|
||||
g.id === groupId
|
||||
? {
|
||||
...g,
|
||||
position: { x: newGroupX, y: newGroupY },
|
||||
style: {
|
||||
...g.style,
|
||||
width: newGroupWidth,
|
||||
height: newGroupHeight,
|
||||
},
|
||||
data: {
|
||||
...g.data,
|
||||
actorIds: [...g.data.actorIds, actorId],
|
||||
|
|
@ -314,17 +377,23 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
|
||||
removeActorFromGroup: (actorId: string, groupId: string) =>
|
||||
set((state) => {
|
||||
// Update actor to remove parent
|
||||
const updatedNodes = state.nodes.map((node) =>
|
||||
node.id === actorId
|
||||
? {
|
||||
...node,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
// Keep current position (will be adjusted in component)
|
||||
}
|
||||
: node
|
||||
);
|
||||
const group = state.groups.find((g) => g.id === groupId);
|
||||
|
||||
// Update actor to remove parent and convert to absolute position
|
||||
const updatedNodes = state.nodes.map((node) => {
|
||||
if (node.id === actorId && group) {
|
||||
return {
|
||||
...node,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
position: {
|
||||
x: group.position.x + node.position.x,
|
||||
y: group.position.y + node.position.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
// Update group's actorIds
|
||||
const updatedGroups = state.groups.map((g) =>
|
||||
|
|
@ -352,12 +421,15 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
|
||||
const isMinimized = !group.data.minimized;
|
||||
|
||||
// Update group's minimized state
|
||||
// Update group's minimized state and child nodes
|
||||
const updatedGroups = state.groups.map((g) => {
|
||||
if (g.id !== groupId) return g;
|
||||
|
||||
if (isMinimized) {
|
||||
// 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 {
|
||||
...g,
|
||||
data: {
|
||||
|
|
@ -365,17 +437,14 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
minimized: true,
|
||||
metadata: {
|
||||
...g.data.metadata,
|
||||
originalWidth: g.width,
|
||||
originalHeight: g.height,
|
||||
originalWidth: currentWidth,
|
||||
originalHeight: currentHeight,
|
||||
},
|
||||
},
|
||||
width: 220,
|
||||
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)
|
||||
...g.style,
|
||||
width: 220,
|
||||
height: 80,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
|
|
@ -394,16 +463,30 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|||
minimized: false,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined,
|
||||
},
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
// Remove wrapper style overrides for maximized state
|
||||
style: undefined,
|
||||
style: {
|
||||
...g.style,
|
||||
width: originalWidth,
|
||||
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 {
|
||||
groups: updatedGroups,
|
||||
nodes: updatedNodes,
|
||||
};
|
||||
}),
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue