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 { useState, useCallback, useEffect } from "react";
|
||||||
import { ReactFlowProvider, useReactFlow } from "reactflow";
|
import { ReactFlowProvider, useReactFlow } from "reactflow";
|
||||||
import GraphEditor from "./components/Editor/GraphEditor";
|
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 DocumentTabs from "./components/Workspace/DocumentTabs";
|
||||||
import MenuBar from "./components/Menu/MenuBar";
|
import MenuBar from "./components/Menu/MenuBar";
|
||||||
import DocumentManager from "./components/Workspace/DocumentManager";
|
import DocumentManager from "./components/Workspace/DocumentManager";
|
||||||
|
|
@ -10,6 +11,8 @@ import { KeyboardShortcutProvider } from "./contexts/KeyboardShortcutContext";
|
||||||
import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts";
|
import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts";
|
||||||
import { useDocumentHistory } from "./hooks/useDocumentHistory";
|
import { useDocumentHistory } from "./hooks/useDocumentHistory";
|
||||||
import { useWorkspaceStore } from "./stores/workspaceStore";
|
import { useWorkspaceStore } from "./stores/workspaceStore";
|
||||||
|
import { usePanelStore } from "./stores/panelStore";
|
||||||
|
import type { Actor, Relation } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App - Root application component
|
* App - Root application component
|
||||||
|
|
@ -34,10 +37,14 @@ import { useWorkspaceStore } from "./stores/workspaceStore";
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { undo, redo } = useDocumentHistory();
|
const { undo, redo } = useDocumentHistory();
|
||||||
const { activeDocumentId } = useWorkspaceStore();
|
const { activeDocumentId } = useWorkspaceStore();
|
||||||
|
const { toggleLeftPanel, toggleRightPanel, leftPanelVisible, rightPanelVisible } = usePanelStore();
|
||||||
const [showDocumentManager, setShowDocumentManager] = useState(false);
|
const [showDocumentManager, setShowDocumentManager] = useState(false);
|
||||||
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
|
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
|
||||||
|
const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
|
||||||
|
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
|
||||||
const { fitView } = useReactFlow();
|
const { fitView } = useReactFlow();
|
||||||
|
|
||||||
|
|
||||||
// Listen for document manager open event from EmptyState
|
// Listen for document manager open event from EmptyState
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenDocumentManager = () => {
|
const handleOpenDocumentManager = () => {
|
||||||
|
|
@ -71,6 +78,33 @@ function AppContent() {
|
||||||
onSelectAll: handleSelectAll,
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-gray-100">
|
<div className="flex flex-col h-screen bg-gray-100">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -102,12 +136,51 @@ function AppContent() {
|
||||||
{/* Document Tabs */}
|
{/* Document Tabs */}
|
||||||
<DocumentTabs />
|
<DocumentTabs />
|
||||||
|
|
||||||
{/* Toolbar - only show when a document is active */}
|
{/* Main content area with side panels */}
|
||||||
{activeDocumentId && <Toolbar />}
|
<main className="flex-1 overflow-hidden flex">
|
||||||
|
{/* Left Panel */}
|
||||||
|
{leftPanelVisible && activeDocumentId && (
|
||||||
|
<LeftPanel
|
||||||
|
onDeselectAll={() => {
|
||||||
|
setSelectedNode(null);
|
||||||
|
setSelectedEdge(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main graph editor */}
|
{/* Center: Graph Editor */}
|
||||||
<main className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<GraphEditor />
|
<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>
|
</main>
|
||||||
|
|
||||||
{/* Document Manager Modal */}
|
{/* Document Manager Modal */}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import ReactFlow, {
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
Viewport,
|
Viewport,
|
||||||
|
useOnSelectionChange,
|
||||||
} from 'reactflow';
|
} from 'reactflow';
|
||||||
import 'reactflow/dist/style.css';
|
import 'reactflow/dist/style.css';
|
||||||
|
|
||||||
|
|
@ -27,8 +28,6 @@ import { useActiveDocument } from '../../stores/workspace/useActiveDocument';
|
||||||
import { useWorkspaceStore } from '../../stores/workspaceStore';
|
import { useWorkspaceStore } from '../../stores/workspaceStore';
|
||||||
import CustomNode from '../Nodes/CustomNode';
|
import CustomNode from '../Nodes/CustomNode';
|
||||||
import CustomEdge from '../Edges/CustomEdge';
|
import CustomEdge from '../Edges/CustomEdge';
|
||||||
import EdgePropertiesPanel from './EdgePropertiesPanel';
|
|
||||||
import NodePropertiesPanel from './NodePropertiesPanel';
|
|
||||||
import ContextMenu from './ContextMenu';
|
import ContextMenu from './ContextMenu';
|
||||||
import EmptyState from '../Common/EmptyState';
|
import EmptyState from '../Common/EmptyState';
|
||||||
import { createNode } from '../../utils/nodeUtils';
|
import { createNode } from '../../utils/nodeUtils';
|
||||||
|
|
@ -38,6 +37,13 @@ import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
|
||||||
import type { Actor, Relation } from '../../types';
|
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
|
* 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
|
* Usage: Core component that wraps React Flow with custom nodes and edges
|
||||||
*/
|
*/
|
||||||
const GraphEditor = () => {
|
const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => {
|
||||||
// Sync with workspace active document
|
// Sync with workspace active document
|
||||||
const { activeDocumentId } = useActiveDocument();
|
const { activeDocumentId } = useActiveDocument();
|
||||||
const { saveViewport, getViewport, createDocument } = useWorkspaceStore();
|
const { saveViewport, getViewport, createDocument } = useWorkspaceStore();
|
||||||
|
|
@ -90,10 +96,6 @@ const GraphEditor = () => {
|
||||||
// Track if a drag is in progress to capture state before drag
|
// Track if a drag is in progress to capture state before drag
|
||||||
const dragInProgressRef = useRef(false);
|
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
|
// Context menu state
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -148,6 +150,35 @@ const GraphEditor = () => {
|
||||||
[activeDocumentId, saveViewport]
|
[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
|
// Sync React Flow state back to store when nodes/edges change
|
||||||
// IMPORTANT: This handler tracks drag operations for undo/redo
|
// IMPORTANT: This handler tracks drag operations for undo/redo
|
||||||
const handleNodesChange = useCallback(
|
const handleNodesChange = useCallback(
|
||||||
|
|
@ -184,8 +215,11 @@ const GraphEditor = () => {
|
||||||
dragInProgressRef.current = false;
|
dragInProgressRef.current = false;
|
||||||
// Debounce to allow React Flow state to settle
|
// Debounce to allow React Flow state to settle
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Sync to store
|
// Sync to store - use callback to get fresh state
|
||||||
setNodes(nodes as Actor[]);
|
setNodesState((currentNodes) => {
|
||||||
|
setNodes(currentNodes as Actor[]);
|
||||||
|
return currentNodes;
|
||||||
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
} else {
|
} else {
|
||||||
// For non-drag changes (dimension, etc), just sync to store
|
// For non-drag changes (dimension, etc), just sync to store
|
||||||
|
|
@ -194,17 +228,21 @@ const GraphEditor = () => {
|
||||||
);
|
);
|
||||||
if (hasNonSelectionChanges) {
|
if (hasNonSelectionChanges) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setNodes(nodes as Actor[]);
|
setNodesState((currentNodes) => {
|
||||||
|
setNodes(currentNodes as Actor[]);
|
||||||
|
return currentNodes;
|
||||||
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onNodesChange, nodes, setNodes, pushToHistory]
|
[onNodesChange, setNodesState, setNodes, pushToHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEdgesChange = useCallback(
|
const handleEdgesChange = useCallback(
|
||||||
(changes: EdgeChange[]) => {
|
(changes: EdgeChange[]) => {
|
||||||
onEdgesChange(changes);
|
onEdgesChange(changes);
|
||||||
|
|
||||||
// Only sync to store for non-selection changes
|
// Only sync to store for non-selection changes
|
||||||
const hasNonSelectionChanges = changes.some(
|
const hasNonSelectionChanges = changes.some(
|
||||||
(change) => change.type !== 'select' && change.type !== 'remove'
|
(change) => change.type !== 'select' && change.type !== 'remove'
|
||||||
|
|
@ -212,11 +250,14 @@ const GraphEditor = () => {
|
||||||
if (hasNonSelectionChanges) {
|
if (hasNonSelectionChanges) {
|
||||||
// Debounce store updates to avoid loops
|
// Debounce store updates to avoid loops
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setEdges(edges as Relation[]);
|
setEdgesState((currentEdges) => {
|
||||||
|
setEdges(currentEdges as Relation[]);
|
||||||
|
return currentEdges;
|
||||||
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onEdgesChange, edges, setEdges]
|
[onEdgesChange, setEdgesState, setEdges]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle new edge connections
|
// Handle new edge connections
|
||||||
|
|
@ -235,18 +276,23 @@ const GraphEditor = () => {
|
||||||
type: edgeType,
|
type: edgeType,
|
||||||
// Don't set label - will use type's label as default
|
// 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
|
// Use React Flow's addEdge helper to properly format the edge
|
||||||
const updatedEdges = addEdge(edgeWithData, storeEdges as 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)
|
// Find the newly added edge (it will be the last one)
|
||||||
const newEdge = updatedEdges[updatedEdges.length - 1] as Relation;
|
const newEdge = updatedEdges[updatedEdges.length - 1] as Relation;
|
||||||
|
|
||||||
// Use the history-tracked addEdge function
|
// Use the history-tracked addEdge function
|
||||||
addEdgeWithHistory(newEdge);
|
addEdgeWithHistory(newEdge);
|
||||||
},
|
},
|
||||||
[storeEdges, edgeTypeConfigs, addEdgeWithHistory, selectedRelationType]
|
[storeEdges, edgeTypeConfigs, addEdgeWithHistory, selectedRelationType, nodes, setNodesState]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle node deletion
|
// Handle node deletion
|
||||||
|
|
@ -285,40 +331,22 @@ const GraphEditor = () => {
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle edge double-click to show properties
|
// Handle node click - ReactFlow handles selection automatically
|
||||||
const handleEdgeDoubleClick = useCallback(
|
const handleNodeClick = useCallback(
|
||||||
(_event: React.MouseEvent, edge: Edge) => {
|
(_event: React.MouseEvent, _node: Node) => {
|
||||||
setSelectedNode(null); // Close node panel if open
|
|
||||||
setSelectedEdge(edge as Relation);
|
|
||||||
setContextMenu(null); // Close context menu if open
|
setContextMenu(null); // Close context menu if open
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle node double-click to show properties
|
// Handle edge click - ReactFlow handles selection automatically
|
||||||
const handleNodeDoubleClick = useCallback(
|
const handleEdgeClick = useCallback(
|
||||||
(_event: React.MouseEvent, node: Node) => {
|
(_event: React.MouseEvent, _edge: Edge) => {
|
||||||
setSelectedEdge(null); // Close edge panel if open
|
|
||||||
setSelectedNode(node as Actor);
|
|
||||||
setContextMenu(null); // Close context menu if open
|
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)
|
// Handle right-click on pane (empty space)
|
||||||
const handlePaneContextMenu = useCallback(
|
const handlePaneContextMenu = useCallback(
|
||||||
(event: React.MouseEvent) => {
|
(event: React.MouseEvent) => {
|
||||||
|
|
@ -379,12 +407,19 @@ const GraphEditor = () => {
|
||||||
|
|
||||||
const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === nodeTypeId);
|
const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === nodeTypeId);
|
||||||
const newNode = createNode(nodeTypeId, position, nodeTypeConfig);
|
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
|
// Use history-tracked addNode instead of setNodes
|
||||||
addNodeWithHistory(newNode);
|
addNodeWithHistory(newNode);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
},
|
},
|
||||||
[contextMenu, screenToFlowPosition, nodeTypeConfigs, addNodeWithHistory]
|
[contextMenu, screenToFlowPosition, nodeTypeConfigs, addNodeWithHistory, nodes, edges, setNodesState, setEdgesState]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show empty state when no document is active
|
// Show empty state when no document is active
|
||||||
|
|
@ -413,8 +448,6 @@ const GraphEditor = () => {
|
||||||
onEdgesDelete={handleEdgesDelete}
|
onEdgesDelete={handleEdgesDelete}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
onEdgeClick={handleEdgeClick}
|
onEdgeClick={handleEdgeClick}
|
||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
|
||||||
onNodeContextMenu={handleNodeContextMenu}
|
onNodeContextMenu={handleNodeContextMenu}
|
||||||
onEdgeContextMenu={handleEdgeContextMenu}
|
onEdgeContextMenu={handleEdgeContextMenu}
|
||||||
onPaneContextMenu={handlePaneContextMenu}
|
onPaneContextMenu={handlePaneContextMenu}
|
||||||
|
|
@ -459,16 +492,6 @@ const GraphEditor = () => {
|
||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|
||||||
{/* Property Panels */}
|
|
||||||
<EdgePropertiesPanel
|
|
||||||
selectedEdge={selectedEdge}
|
|
||||||
onClose={() => setSelectedEdge(null)}
|
|
||||||
/>
|
|
||||||
<NodePropertiesPanel
|
|
||||||
selectedNode={selectedNode}
|
|
||||||
onClose={() => setSelectedNode(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Context Menu - Pane */}
|
{/* Context Menu - Pane */}
|
||||||
{contextMenu && contextMenu.type === 'pane' && (
|
{contextMenu && contextMenu.type === 'pane' && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
|
|
@ -500,7 +523,15 @@ const GraphEditor = () => {
|
||||||
label: 'Edit Properties',
|
label: 'Edit Properties',
|
||||||
icon: <EditIcon fontSize="small" />,
|
icon: <EditIcon fontSize="small" />,
|
||||||
onClick: () => {
|
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);
|
setContextMenu(null);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -539,7 +570,15 @@ const GraphEditor = () => {
|
||||||
label: 'Edit Properties',
|
label: 'Edit Properties',
|
||||||
icon: <EditIcon fontSize="small" />,
|
icon: <EditIcon fontSize="small" />,
|
||||||
onClick: () => {
|
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);
|
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;
|
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)
|
// Read current state directly from store (not from React hooks which might be stale)
|
||||||
const currentState = useGraphStore.getState();
|
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
|
// Create a snapshot of the current state
|
||||||
const snapshot: ConstellationDocument = createDocument(
|
const snapshot: ConstellationDocument = createDocument(
|
||||||
currentState.nodes as never[],
|
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