diff --git a/CLAUDE.md b/CLAUDE.md index 16a77b9..d158378 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index f5029ab..f5cccfd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,9 +47,15 @@ function AppContent() { // Ref for LeftPanel to call focusSearch const leftPanelRef = useRef(null); + // Selection state - single item selection const [selectedNode, setSelectedNode] = useState(null); const [selectedEdge, setSelectedEdge] = useState(null); const [selectedGroup, setSelectedGroup] = useState(null); + + // Multi-selection state + const [selectedActors, setSelectedActors] = useState([]); + const [selectedRelations, setSelectedRelations] = useState([]); + const [selectedGroups, setSelectedGroups] = useState([]); // 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 (
@@ -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([]); }} /> )} diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 8bc474d..b5d7acc 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -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; } @@ -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 diff --git a/src/components/Panels/GroupEditorPanel.tsx b/src/components/Panels/GroupEditorPanel.tsx index f49e7d2..0cebd98 100644 --- a/src/components/Panels/GroupEditorPanel.tsx +++ b/src/components/Panels/GroupEditorPanel.tsx @@ -154,9 +154,11 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => { const groupActors = nodes.filter((node) => selectedGroup.data.actorIds.includes(node.id)); return ( -
- {/* Name */} -
+ <> + {/* Scrollable content */} +
+ {/* Name */} +
@@ -242,9 +244,10 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => {
)}
+
- {/* Actions */} -
+ {/* Footer with actions */} +
{ConfirmDialogComponent} -
+ ); }; diff --git a/src/components/Panels/MultiSelectProperties.tsx b/src/components/Panels/MultiSelectProperties.tsx new file mode 100644 index 0000000..93bd893 --- /dev/null +++ b/src/components/Panels/MultiSelectProperties.tsx @@ -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; + relationTypeBreakdown: Map; + 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(); + selectedActors.forEach((actor) => { + const count = actorTypeBreakdown.get(actor.data.type) || 0; + actorTypeBreakdown.set(actor.data.type, count + 1); + }); + + const relationTypeBreakdown = new Map(); + 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 */} +
+ {/* Selection Summary */} +
+

+ Selection Summary +

+
+ {stats.actorCount > 0 && ( + 1 ? 's' : ''}`} + size="small" + color="primary" + variant="outlined" + /> + )} + {stats.relationCount > 0 && ( + 1 ? 's' : ''}`} + size="small" + color="secondary" + variant="outlined" + /> + )} + {stats.groupCount > 0 && ( + 1 ? 's' : ''}`} + size="small" + color="info" + variant="outlined" + /> + )} +
+ + {/* Type breakdown for actors */} + {stats.actorTypeBreakdown.size > 0 && ( +
+
Actor Types:
+ {Array.from(stats.actorTypeBreakdown.entries()).map(([typeId, count]) => { + const nodeType = nodeTypes.find((t) => t.id === typeId); + return ( +
+
+ + {nodeType?.label || typeId}: {count} + +
+ ); + })} +
+ )} + + {/* Type breakdown for relations */} + {stats.relationTypeBreakdown.size > 0 && ( +
+
Relation Types:
+ {Array.from(stats.relationTypeBreakdown.entries()).map( + ([typeId, count]) => { + const edgeType = edgeTypes.find((t) => t.id === typeId); + return ( +
+
+ + {edgeType?.label || typeId}: {count} + +
+ ); + }, + )} +
+ )} +
+
+ + {/* Footer with actions */} +
+ {/* Actor-specific actions */} + {canAddToGroup && targetGroup && ( + + )} + + {canGroupActors && ( + + )} + + {/* Group-specific actions */} + {selectedGroups.length > 0 && ( + <> + {canMinimizeAll && ( + + )} + + {canMaximizeAll && ( + + )} + + {canUngroupAll && ( + + )} + + + + )} + + {/* Relation-specific actions */} + {selectedRelations.length > 0 && ( +
+
+
+ + + + + + +
+ { + if (newValue !== null) { + handleToggleDirectionality(newValue); + } + }} + fullWidth + size="small" + disabled={processing} + aria-label="relationship directionality" + > + + +
+ + Directed +
+
+
+ + +
+ + Bi +
+
+
+ + +
+ + None +
+
+
+
+ {!allSameDirectionality && ( +

+ Selected relations have different directionalities +

+ )} +
+
+ )} + + {/* General delete action */} + {canDeleteSelection && ( + + )} +
+ + {ConfirmDialogComponent} + + ); +}; + +export default MultiSelectProperties; diff --git a/src/components/Panels/RightPanel.tsx b/src/components/Panels/RightPanel.tsx index 767188b..cd20885 100644 --- a/src/components/Panels/RightPanel.tsx +++ b/src/components/Panels/RightPanel.tsx @@ -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) => (
); -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 ( +
+ + +
+ ); + } + // Group properties view (priority over node/edge if group selected) if (selectedGroup) { return ( diff --git a/src/stores/graphStore.ts b/src/stores/graphStore.ts index 4f224d7..9e7653f 100644 --- a/src/stores/graphStore.ts +++ b/src/stores/graphStore.ts @@ -241,13 +241,25 @@ export const useGraphStore = create((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((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((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((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((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((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, }; }),