mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-26 23:43:40 +00:00
feat: side panels for properties and tools
This commit is contained in:
parent
09b62c69bd
commit
e7ff53dcd7
7 changed files with 3945 additions and 65 deletions
2826
docs/SIDE_PANELS_UX_DESIGN.md
Normal file
2826
docs/SIDE_PANELS_UX_DESIGN.md
Normal file
File diff suppressed because it is too large
Load diff
85
src/App.tsx
85
src/App.tsx
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
288
src/components/Panels/LeftPanel.tsx
Normal file
288
src/components/Panels/LeftPanel.tsx
Normal 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;
|
||||
492
src/components/Panels/RightPanel.tsx
Normal file
492
src/components/Panels/RightPanel.tsx
Normal 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;
|
||||
|
|
@ -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
137
src/stores/panelStore.ts
Normal 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,
|
||||
};
|
||||
Loading…
Reference in a new issue