feat: side panels for properties and tools

This commit is contained in:
Jan-Henrik Bruhn 2025-10-10 18:13:18 +02:00
parent 09b62c69bd
commit e7ff53dcd7
7 changed files with 3945 additions and 65 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
import { useState, useCallback, useEffect } from "react";
import { ReactFlowProvider, useReactFlow } from "reactflow";
import GraphEditor from "./components/Editor/GraphEditor";
import Toolbar from "./components/Toolbar/Toolbar";
import LeftPanel from "./components/Panels/LeftPanel";
import RightPanel from "./components/Panels/RightPanel";
import DocumentTabs from "./components/Workspace/DocumentTabs";
import MenuBar from "./components/Menu/MenuBar";
import DocumentManager from "./components/Workspace/DocumentManager";
@ -10,6 +11,8 @@ import { KeyboardShortcutProvider } from "./contexts/KeyboardShortcutContext";
import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts";
import { useDocumentHistory } from "./hooks/useDocumentHistory";
import { useWorkspaceStore } from "./stores/workspaceStore";
import { usePanelStore } from "./stores/panelStore";
import type { Actor, Relation } from "./types";
/**
* App - Root application component
@ -34,10 +37,14 @@ import { useWorkspaceStore } from "./stores/workspaceStore";
function AppContent() {
const { undo, redo } = useDocumentHistory();
const { activeDocumentId } = useWorkspaceStore();
const { toggleLeftPanel, toggleRightPanel, leftPanelVisible, rightPanelVisible } = usePanelStore();
const [showDocumentManager, setShowDocumentManager] = useState(false);
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
const { fitView } = useReactFlow();
// Listen for document manager open event from EmptyState
useEffect(() => {
const handleOpenDocumentManager = () => {
@ -71,6 +78,33 @@ function AppContent() {
onSelectAll: handleSelectAll,
});
// Panel toggle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+B: Toggle left panel
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
toggleLeftPanel();
}
// Ctrl+I: Toggle right panel
if (e.ctrlKey && e.key === 'i') {
e.preventDefault();
toggleRightPanel();
}
// Escape: Close property panels
if (e.key === 'Escape') {
if (selectedNode || selectedEdge) {
e.preventDefault();
setSelectedNode(null);
setSelectedEdge(null);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleLeftPanel, toggleRightPanel, selectedNode, selectedEdge]);
return (
<div className="flex flex-col h-screen bg-gray-100">
{/* Header */}
@ -102,12 +136,51 @@ function AppContent() {
{/* Document Tabs */}
<DocumentTabs />
{/* Toolbar - only show when a document is active */}
{activeDocumentId && <Toolbar />}
{/* Main content area with side panels */}
<main className="flex-1 overflow-hidden flex">
{/* Left Panel */}
{leftPanelVisible && activeDocumentId && (
<LeftPanel
onDeselectAll={() => {
setSelectedNode(null);
setSelectedEdge(null);
}}
/>
)}
{/* Main graph editor */}
<main className="flex-1 overflow-hidden">
<GraphEditor />
{/* Center: Graph Editor */}
<div className="flex-1 overflow-hidden">
<GraphEditor
selectedNode={selectedNode}
selectedEdge={selectedEdge}
onNodeSelect={(node) => {
setSelectedNode(node);
// Only clear edge if we're setting a node (not clearing)
if (node) {
setSelectedEdge(null);
}
}}
onEdgeSelect={(edge) => {
setSelectedEdge(edge);
// Only clear node if we're setting an edge (not clearing)
if (edge) {
setSelectedNode(null);
}
}}
/>
</div>
{/* Right Panel */}
{rightPanelVisible && activeDocumentId && (
<RightPanel
selectedNode={selectedNode}
selectedEdge={selectedEdge}
onClose={() => {
setSelectedNode(null);
setSelectedEdge(null);
}}
/>
)}
</main>
{/* Document Manager Modal */}

View file

@ -17,6 +17,7 @@ import ReactFlow, {
ConnectionMode,
useReactFlow,
Viewport,
useOnSelectionChange,
} from 'reactflow';
import 'reactflow/dist/style.css';
@ -27,8 +28,6 @@ import { useActiveDocument } from '../../stores/workspace/useActiveDocument';
import { useWorkspaceStore } from '../../stores/workspaceStore';
import CustomNode from '../Nodes/CustomNode';
import CustomEdge from '../Edges/CustomEdge';
import EdgePropertiesPanel from './EdgePropertiesPanel';
import NodePropertiesPanel from './NodePropertiesPanel';
import ContextMenu from './ContextMenu';
import EmptyState from '../Common/EmptyState';
import { createNode } from '../../utils/nodeUtils';
@ -38,6 +37,13 @@ import { useConfirm } from '../../hooks/useConfirm';
import type { Actor, Relation } from '../../types';
interface GraphEditorProps {
selectedNode: Actor | null;
selectedEdge: Relation | null;
onNodeSelect: (node: Actor | null) => void;
onEdgeSelect: (edge: Relation | null) => void;
}
/**
* GraphEditor - Main interactive graph visualization component
*
@ -51,7 +57,7 @@ import type { Actor, Relation } from '../../types';
*
* Usage: Core component that wraps React Flow with custom nodes and edges
*/
const GraphEditor = () => {
const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => {
// Sync with workspace active document
const { activeDocumentId } = useActiveDocument();
const { saveViewport, getViewport, createDocument } = useWorkspaceStore();
@ -90,10 +96,6 @@ const GraphEditor = () => {
// Track if a drag is in progress to capture state before drag
const dragInProgressRef = useRef(false);
// Selected edge/node state for properties panels
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
// Context menu state
const [contextMenu, setContextMenu] = useState<{
x: number;
@ -148,6 +150,35 @@ const GraphEditor = () => {
[activeDocumentId, saveViewport]
);
// Handle selection changes using ReactFlow's dedicated hook
const handleSelectionChange = useCallback(
({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
// If a node is selected, notify parent
if (selectedNodes.length > 0) {
const selectedNode = selectedNodes[0] as Actor;
onNodeSelect(selectedNode);
// Don't call onEdgeSelect - parent will handle clearing edge selection
}
// If an edge is selected, notify parent
else if (selectedEdges.length > 0) {
const selectedEdge = selectedEdges[0] as Relation;
onEdgeSelect(selectedEdge);
// Don't call onNodeSelect - parent will handle clearing node selection
}
// Nothing selected
else {
onNodeSelect(null);
onEdgeSelect(null);
}
},
[onNodeSelect, onEdgeSelect]
);
// Register the selection change handler with ReactFlow
useOnSelectionChange({
onChange: handleSelectionChange,
});
// Sync React Flow state back to store when nodes/edges change
// IMPORTANT: This handler tracks drag operations for undo/redo
const handleNodesChange = useCallback(
@ -184,8 +215,11 @@ const GraphEditor = () => {
dragInProgressRef.current = false;
// Debounce to allow React Flow state to settle
setTimeout(() => {
// Sync to store
setNodes(nodes as Actor[]);
// Sync to store - use callback to get fresh state
setNodesState((currentNodes) => {
setNodes(currentNodes as Actor[]);
return currentNodes;
});
}, 0);
} else {
// For non-drag changes (dimension, etc), just sync to store
@ -194,17 +228,21 @@ const GraphEditor = () => {
);
if (hasNonSelectionChanges) {
setTimeout(() => {
setNodes(nodes as Actor[]);
setNodesState((currentNodes) => {
setNodes(currentNodes as Actor[]);
return currentNodes;
});
}, 0);
}
}
},
[onNodesChange, nodes, setNodes, pushToHistory]
[onNodesChange, setNodesState, setNodes, pushToHistory]
);
const handleEdgesChange = useCallback(
(changes: EdgeChange[]) => {
onEdgesChange(changes);
// Only sync to store for non-selection changes
const hasNonSelectionChanges = changes.some(
(change) => change.type !== 'select' && change.type !== 'remove'
@ -212,11 +250,14 @@ const GraphEditor = () => {
if (hasNonSelectionChanges) {
// Debounce store updates to avoid loops
setTimeout(() => {
setEdges(edges as Relation[]);
setEdgesState((currentEdges) => {
setEdges(currentEdges as Relation[]);
return currentEdges;
});
}, 0);
}
},
[onEdgesChange, edges, setEdges]
[onEdgesChange, setEdgesState, setEdges]
);
// Handle new edge connections
@ -235,18 +276,23 @@ const GraphEditor = () => {
type: edgeType,
// Don't set label - will use type's label as default
},
selected: true, // Auto-select the new edge in ReactFlow
};
// Use React Flow's addEdge helper to properly format the edge
const updatedEdges = addEdge(edgeWithData, storeEdges as Edge[]);
// Deselect all nodes
const updatedNodes = nodes.map((node) => ({ ...node, selected: false }));
setNodesState(updatedNodes as Node[]);
// Find the newly added edge (it will be the last one)
const newEdge = updatedEdges[updatedEdges.length - 1] as Relation;
// Use the history-tracked addEdge function
addEdgeWithHistory(newEdge);
},
[storeEdges, edgeTypeConfigs, addEdgeWithHistory, selectedRelationType]
[storeEdges, edgeTypeConfigs, addEdgeWithHistory, selectedRelationType, nodes, setNodesState]
);
// Handle node deletion
@ -285,40 +331,22 @@ const GraphEditor = () => {
[]
);
// Handle edge double-click to show properties
const handleEdgeDoubleClick = useCallback(
(_event: React.MouseEvent, edge: Edge) => {
setSelectedNode(null); // Close node panel if open
setSelectedEdge(edge as Relation);
// Handle node click - ReactFlow handles selection automatically
const handleNodeClick = useCallback(
(_event: React.MouseEvent, _node: Node) => {
setContextMenu(null); // Close context menu if open
},
[]
);
// Handle node double-click to show properties
const handleNodeDoubleClick = useCallback(
(_event: React.MouseEvent, node: Node) => {
setSelectedEdge(null); // Close edge panel if open
setSelectedNode(node as Actor);
// Handle edge click - ReactFlow handles selection automatically
const handleEdgeClick = useCallback(
(_event: React.MouseEvent, _edge: Edge) => {
setContextMenu(null); // Close context menu if open
},
[]
);
// Handle node click to close context menu
const handleNodeClick = useCallback(() => {
if (contextMenu) {
setContextMenu(null);
}
}, [contextMenu]);
// Handle edge click to close context menu
const handleEdgeClick = useCallback(() => {
if (contextMenu) {
setContextMenu(null);
}
}, [contextMenu]);
// Handle right-click on pane (empty space)
const handlePaneContextMenu = useCallback(
(event: React.MouseEvent) => {
@ -379,12 +407,19 @@ const GraphEditor = () => {
const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === nodeTypeId);
const newNode = createNode(nodeTypeId, position, nodeTypeConfig);
newNode.selected = true; // Auto-select the new node in ReactFlow
// Deselect all existing nodes and edges BEFORE adding the new one
const updatedNodes = nodes.map((node) => ({ ...node, selected: false }));
const updatedEdges = edges.map((edge) => ({ ...edge, selected: false }));
setNodesState(updatedNodes as Node[]);
setEdgesState(updatedEdges as Edge[]);
// Use history-tracked addNode instead of setNodes
addNodeWithHistory(newNode);
setContextMenu(null);
},
[contextMenu, screenToFlowPosition, nodeTypeConfigs, addNodeWithHistory]
[contextMenu, screenToFlowPosition, nodeTypeConfigs, addNodeWithHistory, nodes, edges, setNodesState, setEdgesState]
);
// Show empty state when no document is active
@ -413,8 +448,6 @@ const GraphEditor = () => {
onEdgesDelete={handleEdgesDelete}
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeContextMenu={handleNodeContextMenu}
onEdgeContextMenu={handleEdgeContextMenu}
onPaneContextMenu={handlePaneContextMenu}
@ -459,16 +492,6 @@ const GraphEditor = () => {
/>
</ReactFlow>
{/* Property Panels */}
<EdgePropertiesPanel
selectedEdge={selectedEdge}
onClose={() => setSelectedEdge(null)}
/>
<NodePropertiesPanel
selectedNode={selectedNode}
onClose={() => setSelectedNode(null)}
/>
{/* Context Menu - Pane */}
{contextMenu && contextMenu.type === 'pane' && (
<ContextMenu
@ -500,7 +523,15 @@ const GraphEditor = () => {
label: 'Edit Properties',
icon: <EditIcon fontSize="small" />,
onClick: () => {
setSelectedNode(contextMenu.target as Actor);
// Select the node in ReactFlow (which will trigger the right panel)
const nodeId = contextMenu.target!.id;
const updatedNodes = nodes.map((n) => ({
...n,
selected: n.id === nodeId,
}));
const updatedEdges = edges.map((e) => ({ ...e, selected: false }));
setNodesState(updatedNodes as Node[]);
setEdgesState(updatedEdges as Edge[]);
setContextMenu(null);
},
},
@ -539,7 +570,15 @@ const GraphEditor = () => {
label: 'Edit Properties',
icon: <EditIcon fontSize="small" />,
onClick: () => {
setSelectedEdge(contextMenu.target as Relation);
// Select the edge in ReactFlow (which will trigger the right panel)
const edgeId = contextMenu.target!.id;
const updatedEdges = edges.map((e) => ({
...e,
selected: e.id === edgeId,
}));
const updatedNodes = nodes.map((n) => ({ ...n, selected: false }));
setEdgesState(updatedEdges as Edge[]);
setNodesState(updatedNodes as Node[]);
setContextMenu(null);
},
},

View file

@ -0,0 +1,288 @@
import { useCallback } from 'react';
import { IconButton, Tooltip } from '@mui/material';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { usePanelStore } from '../../stores/panelStore';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useEditorStore } from '../../stores/editorStore';
import { useDocumentHistory } from '../../hooks/useDocumentHistory';
import { createNode } from '../../utils/nodeUtils';
/**
* LeftPanel - Collapsible tools panel on the left side
*
* Features:
* - Collapsible sections (History, Add Actors, Relations, Layout, View, Search)
* - Drag-and-drop actor creation
* - Undo/Redo with descriptions
* - Relation type selector
* - Collapse to icon bar (40px)
* - Resizable width
*/
interface LeftPanelProps {
onDeselectAll: () => void;
}
const LeftPanel = ({ onDeselectAll }: LeftPanelProps) => {
const {
leftPanelCollapsed,
leftPanelWidth,
leftPanelSections,
toggleLeftPanelSection,
collapseLeftPanel,
expandLeftPanel,
} = usePanelStore();
const { nodeTypes, edgeTypes, addNode } = useGraphWithHistory();
const { selectedRelationType, setSelectedRelationType } = useEditorStore();
const { undo, redo, canUndo, canRedo, undoDescription, redoDescription } = useDocumentHistory();
const handleAddNode = useCallback(
(nodeTypeId: string) => {
// Deselect all other nodes/edges first
onDeselectAll();
// Create node at center of viewport (approximate)
const position = {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
};
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeTypeId);
const newNode = createNode(nodeTypeId, position, nodeTypeConfig);
newNode.selected = true; // Auto-select in ReactFlow
addNode(newNode);
},
[addNode, nodeTypes, onDeselectAll]
);
const selectedEdgeTypeConfig = edgeTypes.find(et => et.id === selectedRelationType);
// Collapsed icon bar view
if (leftPanelCollapsed) {
return (
<div className="h-full bg-gray-50 border-r border-gray-200 flex flex-col items-center py-2 space-y-2" style={{ width: '40px' }}>
<Tooltip title="Expand Tools Panel (Ctrl+B)" placement="right">
<IconButton size="small" onClick={expandLeftPanel}>
<ChevronRightIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* Icon indicators for quick reference */}
<div className="flex-1 flex flex-col items-center space-y-4 pt-4">
<Tooltip title={`Undo: ${undoDescription || 'Nothing to undo'}`} placement="right">
<span>
<IconButton size="small" onClick={undo} disabled={!canUndo}>
<UndoIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</div>
</div>
);
}
// Expanded panel view
return (
<div
className="h-full bg-white border-r border-gray-200 flex flex-col overflow-hidden"
style={{ width: `${leftPanelWidth}px` }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-gray-50">
<h2 className="text-sm font-semibold text-gray-700">Tools</h2>
<Tooltip title="Collapse Panel (Ctrl+B)">
<IconButton size="small" onClick={collapseLeftPanel}>
<ChevronLeftIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden">
{/* History Section */}
<div className="border-b border-gray-200">
<button
onClick={() => toggleLeftPanelSection('history')}
className="w-full px-3 py-2 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
>
<span className="text-xs font-semibold text-gray-700">History</span>
{leftPanelSections.history ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</button>
{leftPanelSections.history && (
<div className="px-3 py-3 space-y-2">
<div className="flex items-center space-x-1">
<Tooltip
title={undoDescription ? `Undo: ${undoDescription}` : 'Undo (Ctrl+Z)'}
arrow
>
<span className="flex-1">
<button
onClick={undo}
disabled={!canUndo}
className="w-full flex items-center space-x-2 px-2 py-1.5 text-xs font-medium text-gray-700 bg-gray-50 rounded hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<UndoIcon fontSize="small" />
<span>Undo</span>
</button>
</span>
</Tooltip>
<Tooltip
title={redoDescription ? `Redo: ${redoDescription}` : 'Redo (Ctrl+Y)'}
arrow
>
<span className="flex-1">
<button
onClick={redo}
disabled={!canRedo}
className="w-full flex items-center space-x-2 px-2 py-1.5 text-xs font-medium text-gray-700 bg-gray-50 rounded hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RedoIcon fontSize="small" />
<span>Redo</span>
</button>
</span>
</Tooltip>
</div>
{undoDescription && (
<p className="text-xs text-gray-500 italic">
Next: {undoDescription}
</p>
)}
</div>
)}
</div>
{/* Add Actors Section */}
<div className="border-b border-gray-200">
<button
onClick={() => toggleLeftPanelSection('addActors')}
className="w-full px-3 py-2 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
>
<span className="text-xs font-semibold text-gray-700">Add Actors</span>
{leftPanelSections.addActors ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</button>
{leftPanelSections.addActors && (
<div className="px-3 py-3 space-y-2">
{nodeTypes.map((nodeType) => (
<button
key={nodeType.id}
onClick={() => handleAddNode(nodeType.id)}
className="w-full flex items-center space-x-3 px-3 py-2.5 text-sm font-medium text-white rounded hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-offset-2 shadow-sm"
style={{ backgroundColor: nodeType.color }}
title={nodeType.description}
>
<div
className="w-3 h-3 rounded-full border-2 border-white"
style={{ backgroundColor: nodeType.color }}
/>
<span className="flex-1 text-left">{nodeType.label}</span>
</button>
))}
<p className="text-xs text-gray-500 pt-2 border-t border-gray-100">
Click a button to add an actor to the canvas
</p>
</div>
)}
</div>
{/* Relations Section */}
<div className="border-b border-gray-200">
<button
onClick={() => toggleLeftPanelSection('relations')}
className="w-full px-3 py-2 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
>
<span className="text-xs font-semibold text-gray-700">Relations</span>
{leftPanelSections.relations ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</button>
{leftPanelSections.relations && (
<div className="px-3 py-3 space-y-2">
<label className="block text-xs font-medium text-gray-600 mb-1">
Active Type
</label>
<select
value={selectedRelationType || ''}
onChange={(e) => setSelectedRelationType(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
style={{
borderLeftWidth: '4px',
borderLeftColor: selectedEdgeTypeConfig?.color || '#6b7280'
}}
>
{edgeTypes.map((edgeType) => (
<option key={edgeType.id} value={edgeType.id}>
{edgeType.label}
</option>
))}
</select>
<p className="text-xs text-gray-500 pt-1">
Drag from actor handles to create relations
</p>
</div>
)}
</div>
{/* Layout Section */}
<div className="border-b border-gray-200">
<button
onClick={() => toggleLeftPanelSection('layout')}
className="w-full px-3 py-2 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
>
<span className="text-xs font-semibold text-gray-700">Layout</span>
{leftPanelSections.layout ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</button>
{leftPanelSections.layout && (
<div className="px-3 py-3">
<p className="text-xs text-gray-500 italic">
Auto-layout features coming soon
</p>
</div>
)}
</div>
{/* View Section */}
<div className="border-b border-gray-200">
<button
onClick={() => toggleLeftPanelSection('view')}
className="w-full px-3 py-2 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
>
<span className="text-xs font-semibold text-gray-700">View</span>
{leftPanelSections.view ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</button>
{leftPanelSections.view && (
<div className="px-3 py-3">
<p className="text-xs text-gray-500 italic">
View options coming soon
</p>
</div>
)}
</div>
{/* Search Section */}
<div className="border-b border-gray-200">
<button
onClick={() => toggleLeftPanelSection('search')}
className="w-full px-3 py-2 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
>
<span className="text-xs font-semibold text-gray-700">Search & Filter</span>
{leftPanelSections.search ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</button>
{leftPanelSections.search && (
<div className="px-3 py-3">
<p className="text-xs text-gray-500 italic">
Search features coming soon
</p>
</div>
)}
</div>
</div>
</div>
);
};
export default LeftPanel;

View file

@ -0,0 +1,492 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { IconButton, Tooltip } from '@mui/material';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import DeleteIcon from '@mui/icons-material/Delete';
import { usePanelStore } from '../../stores/panelStore';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useConfirm } from '../../hooks/useConfirm';
import type { Actor, Relation } from '../../types';
/**
* RightPanel - Context-aware properties panel on the right side
*
* Features:
* - Shows properties of selected node(s) or edge(s)
* - Live property updates (no save button)
* - Connection information for actors
* - Multi-selection support
* - Non-modal design (doesn't block graph view)
* - Collapsible
*/
interface Props {
selectedNode: Actor | null;
selectedEdge: Relation | null;
onClose: () => void;
}
const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
const {
rightPanelCollapsed,
rightPanelWidth,
collapseRightPanel,
expandRightPanel,
} = usePanelStore();
const { nodeTypes, edgeTypes, updateNode, updateEdge, deleteNode, deleteEdge, edges } = useGraphWithHistory();
const { confirm, ConfirmDialogComponent } = useConfirm();
// Node property states
const [actorType, setActorType] = useState('');
const [actorLabel, setActorLabel] = useState('');
const [actorDescription, setActorDescription] = useState('');
const labelInputRef = useRef<HTMLInputElement>(null);
// Edge property states
const [relationType, setRelationType] = useState('');
const [relationLabel, setRelationLabel] = useState('');
// Track if user has made changes
const [hasNodeChanges, setHasNodeChanges] = useState(false);
const [hasEdgeChanges, setHasEdgeChanges] = useState(false);
// Update state when selected node changes
useEffect(() => {
if (selectedNode) {
setActorType(selectedNode.data?.type || '');
setActorLabel(selectedNode.data?.label || '');
setActorDescription(selectedNode.data?.description || '');
setHasNodeChanges(false);
// Focus and select the label input when node is selected
setTimeout(() => {
if (labelInputRef.current && !rightPanelCollapsed) {
labelInputRef.current.focus();
labelInputRef.current.select();
}
}, 100);
}
}, [selectedNode, rightPanelCollapsed]);
// Update state when selected edge changes
useEffect(() => {
if (selectedEdge && selectedEdge.data) {
setRelationType(selectedEdge.data.type || '');
const typeLabel = edgeTypes.find((et) => et.id === selectedEdge.data?.type)?.label;
const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel;
setRelationLabel((hasCustomLabel && selectedEdge.data.label) || '');
setHasEdgeChanges(false);
}
}, [selectedEdge, edgeTypes]);
// Live update node properties (debounced)
const updateNodeProperties = useCallback(() => {
if (!selectedNode || !hasNodeChanges) return;
updateNode(selectedNode.id, {
data: {
type: actorType,
label: actorLabel,
description: actorDescription || undefined,
},
});
setHasNodeChanges(false);
}, [selectedNode, actorType, actorLabel, actorDescription, hasNodeChanges, updateNode]);
// Live update edge properties (debounced)
const updateEdgeProperties = useCallback(() => {
if (!selectedEdge || !hasEdgeChanges) return;
updateEdge(selectedEdge.id, {
type: relationType,
label: relationLabel.trim() || undefined,
});
setHasEdgeChanges(false);
}, [selectedEdge, relationType, relationLabel, hasEdgeChanges, updateEdge]);
// Debounce live updates
useEffect(() => {
const timeoutId = setTimeout(() => {
if (hasNodeChanges) {
updateNodeProperties();
}
if (hasEdgeChanges) {
updateEdgeProperties();
}
}, 500); // 500ms debounce
return () => clearTimeout(timeoutId);
}, [hasNodeChanges, hasEdgeChanges, updateNodeProperties, updateEdgeProperties]);
// Handle node deletion
const handleDeleteNode = async () => {
if (!selectedNode) return;
const confirmed = await confirm({
title: 'Delete Actor',
message: 'Are you sure you want to delete this actor? All connected relations will also be deleted.',
confirmLabel: 'Delete',
severity: 'danger',
});
if (confirmed) {
deleteNode(selectedNode.id);
onClose();
}
};
// Handle edge deletion
const handleDeleteEdge = async () => {
if (!selectedEdge) return;
const confirmed = await confirm({
title: 'Delete Relation',
message: 'Are you sure you want to delete this relation?',
confirmLabel: 'Delete',
severity: 'danger',
});
if (confirmed) {
deleteEdge(selectedEdge.id);
onClose();
}
};
// Get connections for selected node
const getNodeConnections = () => {
if (!selectedNode) return [];
return edges.filter(
(edge) => edge.source === selectedNode.id || edge.target === selectedNode.id
);
};
const selectedNodeTypeConfig = nodeTypes.find((nt) => nt.id === actorType);
const selectedEdgeTypeConfig = edgeTypes.find((et) => et.id === relationType);
// Collapsed view
if (rightPanelCollapsed) {
return (
<div className="h-full bg-gray-50 border-l border-gray-200 flex flex-col items-center py-2" style={{ width: '40px' }}>
<Tooltip title="Expand Properties Panel (Ctrl+I)" placement="left">
<IconButton size="small" onClick={expandRightPanel}>
<ChevronLeftIcon fontSize="small" />
</IconButton>
</Tooltip>
{ConfirmDialogComponent}
</div>
);
}
// No selection state
if (!selectedNode && !selectedEdge) {
return (
<div
className="h-full bg-white border-l border-gray-200 flex flex-col"
style={{ width: `${rightPanelWidth}px` }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-gray-50">
<h2 className="text-sm font-semibold text-gray-700">Properties</h2>
<Tooltip title="Collapse Panel (Ctrl+I)">
<IconButton size="small" onClick={collapseRightPanel}>
<ChevronRightIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
{/* Empty state */}
<div className="flex-1 flex items-center justify-center px-4">
<div className="text-center text-gray-400">
<p className="text-sm font-medium">No Selection</p>
<p className="text-xs mt-1">
Select an actor or relation to view properties
</p>
</div>
</div>
{ConfirmDialogComponent}
</div>
);
}
// Node properties view
if (selectedNode) {
const connections = getNodeConnections();
return (
<div
className="h-full bg-white border-l border-gray-200 flex flex-col"
style={{ width: `${rightPanelWidth}px` }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-gray-50">
<h2 className="text-sm font-semibold text-gray-700">Actor Properties</h2>
<div className="flex items-center space-x-1">
<Tooltip title="Close (Esc)">
<IconButton size="small" onClick={onClose}>
</IconButton>
</Tooltip>
<Tooltip title="Collapse Panel (Ctrl+I)">
<IconButton size="small" onClick={collapseRightPanel}>
<ChevronRightIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
{/* Actor Type */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Actor Type
</label>
<select
value={actorType}
onChange={(e) => {
setActorType(e.target.value);
setHasNodeChanges(true);
}}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{nodeTypes.map((nodeType) => (
<option key={nodeType.id} value={nodeType.id}>
{nodeType.label}
</option>
))}
</select>
{selectedNodeTypeConfig && (
<div
className="mt-2 h-8 rounded border-2 flex items-center justify-center text-xs font-medium text-white"
style={{
backgroundColor: selectedNodeTypeConfig.color,
borderColor: selectedNodeTypeConfig.color,
}}
>
{selectedNodeTypeConfig.label}
</div>
)}
</div>
{/* Actor Label */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Label *
</label>
<input
ref={labelInputRef}
type="text"
value={actorLabel}
onChange={(e) => {
setActorLabel(e.target.value);
setHasNodeChanges(true);
}}
placeholder="Enter actor name"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Description */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Description (optional)
</label>
<textarea
value={actorDescription}
onChange={(e) => {
setActorDescription(e.target.value);
setHasNodeChanges(true);
}}
placeholder="Add a description"
rows={3}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
{/* Connections */}
<div className="pt-3 border-t border-gray-200">
<h3 className="text-xs font-semibold text-gray-700 mb-2">
Connections ({connections.length})
</h3>
{connections.length === 0 ? (
<p className="text-xs text-gray-500 italic">No connections</p>
) : (
<div className="space-y-1">
{connections.map((edge) => {
const edgeConfig = edgeTypes.find((et) => et.id === edge.data?.type);
const isOutgoing = edge.source === selectedNode.id;
const otherId = isOutgoing ? edge.target : edge.source;
return (
<div
key={edge.id}
className="text-xs text-gray-600 flex items-center space-x-1"
>
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: edgeConfig?.color || '#6b7280' }}
/>
<span className="font-medium">{edgeConfig?.label || 'Unknown'}</span>
<span>{isOutgoing ? '→' : '←'}</span>
<span className="text-gray-500">{otherId}</span>
</div>
);
})}
</div>
)}
</div>
{/* Node Info */}
<div className="pt-3 border-t border-gray-200">
<p className="text-xs text-gray-500">
<span className="font-medium">Node ID:</span> {selectedNode.id}
</p>
<p className="text-xs text-gray-500 mt-1">
<span className="font-medium">Position:</span> ({Math.round(selectedNode.position.x)}, {Math.round(selectedNode.position.y)})
</p>
</div>
</div>
{/* Footer with actions */}
<div className="px-3 py-3 border-t border-gray-200 bg-gray-50">
<button
onClick={handleDeleteNode}
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 Actor</span>
</button>
{hasNodeChanges && (
<p className="text-xs text-gray-500 mt-2 text-center italic">
Saving changes...
</p>
)}
</div>
{ConfirmDialogComponent}
</div>
);
}
// Edge properties view
if (selectedEdge) {
const renderStylePreview = () => {
if (!selectedEdgeTypeConfig) return null;
const strokeDasharray = {
solid: '0',
dashed: '8,4',
dotted: '2,4',
}[selectedEdgeTypeConfig.style || 'solid'];
return (
<svg width="100%" height="20" className="mt-2">
<line
x1="0"
y1="10"
x2="100%"
y2="10"
stroke={selectedEdgeTypeConfig.color}
strokeWidth="3"
strokeDasharray={strokeDasharray}
/>
</svg>
);
};
return (
<div
className="h-full bg-white border-l border-gray-200 flex flex-col"
style={{ width: `${rightPanelWidth}px` }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-gray-50">
<h2 className="text-sm font-semibold text-gray-700">Relation Properties</h2>
<div className="flex items-center space-x-1">
<Tooltip title="Close (Esc)">
<IconButton size="small" onClick={onClose}>
</IconButton>
</Tooltip>
<Tooltip title="Collapse Panel (Ctrl+I)">
<IconButton size="small" onClick={collapseRightPanel}>
<ChevronRightIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
{/* Relation Type */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Relation Type
</label>
<select
value={relationType}
onChange={(e) => {
setRelationType(e.target.value);
setHasEdgeChanges(true);
}}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{edgeTypes.map((edgeType) => (
<option key={edgeType.id} value={edgeType.id}>
{edgeType.label}
</option>
))}
</select>
{renderStylePreview()}
</div>
{/* Custom Label */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Custom Label (optional)
</label>
<input
type="text"
value={relationLabel}
onChange={(e) => {
setRelationLabel(e.target.value);
setHasEdgeChanges(true);
}}
placeholder={selectedEdgeTypeConfig?.label || 'Enter label'}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
Leave empty to use default type label
</p>
</div>
{/* Connection Info */}
<div className="pt-3 border-t border-gray-200">
<p className="text-xs text-gray-500">
<span className="font-medium">From:</span> {selectedEdge.source}
</p>
<p className="text-xs text-gray-500 mt-1">
<span className="font-medium">To:</span> {selectedEdge.target}
</p>
</div>
</div>
{/* Footer with actions */}
<div className="px-3 py-3 border-t border-gray-200 bg-gray-50">
<button
onClick={handleDeleteEdge}
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 Relation</span>
</button>
{hasEdgeChanges && (
<p className="text-xs text-gray-500 mt-2 text-center italic">
Saving changes...
</p>
)}
</div>
{ConfirmDialogComponent}
</div>
);
}
return null;
};
export default RightPanel;

View file

@ -51,15 +51,40 @@ export function useDocumentHistory() {
return;
}
const currentDoc = getActiveDocument();
if (!currentDoc) {
console.warn('Active document not loaded');
return;
}
// Read current state directly from store (not from React hooks which might be stale)
const currentState = useGraphStore.getState();
const currentDoc = getActiveDocument();
if (!currentDoc) {
console.warn('Active document not loaded, attempting to use current graph state');
// If document isn't loaded yet, create a minimal snapshot from current state
const snapshot: ConstellationDocument = createDocument(
currentState.nodes as never[],
currentState.edges as never[],
currentState.nodeTypes,
currentState.edgeTypes
);
// Use minimal metadata
snapshot.metadata = {
documentId: activeDocumentId,
title: 'Untitled',
version: '1.0.0',
appName: 'Constellation Analyzer',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastSavedBy: 'user',
};
// Push to history
historyStore.pushAction(activeDocumentId, {
description,
timestamp: Date.now(),
documentState: snapshot,
});
return;
}
// Create a snapshot of the current state
const snapshot: ConstellationDocument = createDocument(
currentState.nodes as never[],

137
src/stores/panelStore.ts Normal file
View file

@ -0,0 +1,137 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/**
* Panel Store - Manages state of collapsible side panels
*
* Features:
* - Left panel (tools) visibility and width
* - Right panel (properties) visibility and width
* - Panel state persistence to localStorage
* - Collapsed section state within panels
*/
interface PanelState {
// Left Panel
leftPanelVisible: boolean;
leftPanelWidth: number;
leftPanelCollapsed: boolean;
leftPanelSections: {
history: boolean;
addActors: boolean;
relations: boolean;
layout: boolean;
view: boolean;
search: boolean;
};
// Right Panel
rightPanelVisible: boolean;
rightPanelWidth: number;
rightPanelCollapsed: boolean;
// Actions
toggleLeftPanel: () => void;
toggleRightPanel: () => void;
setLeftPanelWidth: (width: number) => void;
setRightPanelWidth: (width: number) => void;
toggleLeftPanelSection: (section: keyof PanelState['leftPanelSections']) => void;
collapseLeftPanel: () => void;
expandLeftPanel: () => void;
collapseRightPanel: () => void;
expandRightPanel: () => void;
}
const DEFAULT_LEFT_WIDTH = 280;
const DEFAULT_RIGHT_WIDTH = 320;
const MIN_LEFT_WIDTH = 240;
const MAX_LEFT_WIDTH = 400;
const MIN_RIGHT_WIDTH = 280;
const MAX_RIGHT_WIDTH = 500;
const COLLAPSED_LEFT_WIDTH = 40;
export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
// Initial state
leftPanelVisible: true,
leftPanelWidth: DEFAULT_LEFT_WIDTH,
leftPanelCollapsed: false,
leftPanelSections: {
history: true,
addActors: true,
relations: true,
layout: false,
view: false,
search: false,
},
rightPanelVisible: true,
rightPanelWidth: DEFAULT_RIGHT_WIDTH,
rightPanelCollapsed: false,
// Actions
toggleLeftPanel: () =>
set((state) => ({
leftPanelVisible: !state.leftPanelVisible,
})),
toggleRightPanel: () =>
set((state) => ({
rightPanelVisible: !state.rightPanelVisible,
})),
setLeftPanelWidth: (width: number) =>
set(() => ({
leftPanelWidth: Math.max(MIN_LEFT_WIDTH, Math.min(MAX_LEFT_WIDTH, width)),
})),
setRightPanelWidth: (width: number) =>
set(() => ({
rightPanelWidth: Math.max(MIN_RIGHT_WIDTH, Math.min(MAX_RIGHT_WIDTH, width)),
})),
toggleLeftPanelSection: (section) =>
set((state) => ({
leftPanelSections: {
...state.leftPanelSections,
[section]: !state.leftPanelSections[section],
},
})),
collapseLeftPanel: () =>
set(() => ({
leftPanelCollapsed: true,
})),
expandLeftPanel: () =>
set(() => ({
leftPanelCollapsed: false,
})),
collapseRightPanel: () =>
set(() => ({
rightPanelCollapsed: true,
})),
expandRightPanel: () =>
set(() => ({
rightPanelCollapsed: false,
})),
}),
{
name: 'constellation-panel-state',
}
)
);
// Export constants for use in components
export const PANEL_CONSTANTS = {
DEFAULT_LEFT_WIDTH,
DEFAULT_RIGHT_WIDTH,
MIN_LEFT_WIDTH,
MAX_LEFT_WIDTH,
MIN_RIGHT_WIDTH,
MAX_RIGHT_WIDTH,
COLLAPSED_LEFT_WIDTH,
};