refactor: extract RightPanel into separate specialized components

Improves code organization and maintainability by separating panel
implementations into focused, single-responsibility components.

Changes:
- Extract NodeEditorPanel for actor property editing
- Extract EdgeEditorPanel for relation property editing
- Extract GraphAnalysisPanel for graph metrics display
- Simplify RightPanel to act as routing/layout component (745→114 lines)
- Remove old unused Editor/NodePropertiesPanel.tsx
- Remove old unused Editor/EdgePropertiesPanel.tsx

All existing functionality preserved including live updates, debouncing,
modals, and connection displays. Each panel now self-contained with its
own state management and logic.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-17 10:46:14 +02:00
parent d98acf963b
commit 61a13383dc
6 changed files with 658 additions and 961 deletions

View file

@ -1,155 +0,0 @@
import { useState, useEffect } from 'react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import PropertyPanel from '../Common/PropertyPanel';
import LabelSelector from '../Common/LabelSelector';
import type { Relation } from '../../types';
/**
* EdgePropertiesPanel - Side panel for editing edge/relation properties
*
* Features:
* - Change relation type
* - Edit relation label
* - Delete relation
* - Visual preview of line style
*/
interface Props {
selectedEdge: Relation | null;
onClose: () => void;
}
const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => {
const { edgeTypes, updateEdge, deleteEdge } = useGraphWithHistory();
const [relationType, setRelationType] = useState('');
const [relationLabel, setRelationLabel] = useState('');
const [relationLabels, setRelationLabels] = useState<string[]>([]);
useEffect(() => {
if (selectedEdge && selectedEdge.data) {
setRelationType(selectedEdge.data.type || '');
// Only show custom label if it exists and differs from type label
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) || '');
setRelationLabels(selectedEdge.data.labels || []);
}
}, [selectedEdge, edgeTypes]);
const handleSave = () => {
if (!selectedEdge) return;
updateEdge(selectedEdge.id, {
type: relationType,
// Only set label if user provided a custom one (not empty)
label: relationLabel.trim() || undefined,
labels: relationLabels.length > 0 ? relationLabels : undefined,
});
onClose();
};
const handleDelete = () => {
if (!selectedEdge) return;
deleteEdge(selectedEdge.id);
onClose();
};
const selectedEdgeTypeConfig = edgeTypes.find((et) => et.id === relationType);
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 (
<PropertyPanel
isOpen={!!selectedEdge}
title="Relation Properties"
onClose={onClose}
onSave={handleSave}
onDelete={handleDelete}
deleteConfirmMessage="Are you sure you want to delete this relation?"
deleteButtonLabel="Delete"
>
{/* 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)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md 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)}
placeholder={selectedEdgeTypeConfig?.label || 'Enter label'}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md 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>
{/* Labels */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Labels (optional)
</label>
<LabelSelector
value={relationLabels}
onChange={setRelationLabels}
scope="relations"
/>
</div>
{/* Connection Info */}
{selectedEdge && (
<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>
)}
</PropertyPanel>
);
};
export default EdgePropertiesPanel;

View file

@ -1,164 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import PropertyPanel from '../Common/PropertyPanel';
import LabelSelector from '../Common/LabelSelector';
import type { Actor } from '../../types';
/**
* NodePropertiesPanel - Side panel for editing node/actor properties
*
* Features:
* - Change actor type
* - Edit actor label
* - Edit description
* - Delete actor
* - Visual color preview
*/
interface Props {
selectedNode: Actor | null;
onClose: () => void;
}
const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => {
const { nodeTypes, updateNode, deleteNode } = useGraphWithHistory();
const [actorType, setActorType] = useState('');
const [actorLabel, setActorLabel] = useState('');
const [actorDescription, setActorDescription] = useState('');
const [actorLabels, setActorLabels] = useState<string[]>([]);
const labelInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selectedNode) {
setActorType(selectedNode.data?.type || '');
setActorLabel(selectedNode.data?.label || '');
setActorDescription(selectedNode.data?.description || '');
setActorLabels(selectedNode.data?.labels || []);
// Focus and select the label input when panel opens
setTimeout(() => {
if (labelInputRef.current) {
labelInputRef.current.focus();
labelInputRef.current.select();
}
}, 100); // Small delay to ensure panel animation completes
}
}, [selectedNode]);
const handleSave = () => {
if (!selectedNode) return;
updateNode(selectedNode.id, {
data: {
type: actorType,
label: actorLabel,
description: actorDescription || undefined,
labels: actorLabels.length > 0 ? actorLabels : undefined,
},
});
onClose();
};
const handleDelete = () => {
if (!selectedNode) return;
deleteNode(selectedNode.id);
onClose();
};
const selectedNodeTypeConfig = nodeTypes.find((nt) => nt.id === actorType);
return (
<PropertyPanel
isOpen={!!selectedNode}
title="Actor Properties"
onClose={onClose}
onSave={handleSave}
onDelete={handleDelete}
deleteConfirmMessage="Are you sure you want to delete this actor? All connected relations will also be deleted."
deleteButtonLabel="Delete"
>
{/* 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)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md 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,
}}
>
Color Preview
</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)}
placeholder="Enter actor name"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md 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)}
placeholder="Add a description"
rows={3}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
{/* Labels */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Labels (optional)
</label>
<LabelSelector
value={actorLabels}
onChange={setActorLabels}
scope="actors"
/>
</div>
{/* Node Info */}
{selectedNode && (
<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>
)}
</PropertyPanel>
);
};
export default NodePropertiesPanel;

View file

@ -0,0 +1,333 @@
import { useState, useEffect, useCallback } from 'react';
import { IconButton, Tooltip, ToggleButton, ToggleButtonGroup } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import SyncAltIcon from '@mui/icons-material/SyncAlt';
import RemoveIcon from '@mui/icons-material/Remove';
import EditIcon from '@mui/icons-material/Edit';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useDocumentHistory } from '../../hooks/useDocumentHistory';
import { useConfirm } from '../../hooks/useConfirm';
import ConnectionDisplay from '../Common/ConnectionDisplay';
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
import type { Relation, EdgeDirectionality } from '../../types';
interface EdgeEditorPanelProps {
selectedEdge: Relation;
onClose: () => void;
}
const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
const { nodes, edges, nodeTypes, edgeTypes, updateEdge, deleteEdge, setEdges } = useGraphWithHistory();
const { pushToHistory } = useDocumentHistory();
const { confirm, ConfirmDialogComponent } = useConfirm();
// Edge property states
const [relationType, setRelationType] = useState('');
const [relationLabel, setRelationLabel] = useState('');
const [relationLabels, setRelationLabels] = useState<string[]>([]);
const [relationDirectionality, setRelationDirectionality] = useState<EdgeDirectionality>('directed');
// Track if user has made changes
const [hasEdgeChanges, setHasEdgeChanges] = useState(false);
// Relation type modal state
const [showRelationTypeModal, setShowRelationTypeModal] = useState(false);
const [editingRelationTypeId, setEditingRelationTypeId] = useState<string | null>(null);
// Update state when selected edge changes
useEffect(() => {
if (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) || '');
setRelationLabels(selectedEdge.data.labels || []);
const edgeTypeConfig = edgeTypes.find((et) => et.id === selectedEdge.data?.type);
setRelationDirectionality(selectedEdge.data.directionality || edgeTypeConfig?.defaultDirectionality || 'directed');
setHasEdgeChanges(false);
}
}, [selectedEdge, edgeTypes]);
// Live update edge properties (debounced)
const updateEdgeProperties = useCallback(() => {
if (!hasEdgeChanges) return;
updateEdge(selectedEdge.id, {
type: relationType,
label: relationLabel.trim() || undefined,
directionality: relationDirectionality,
labels: relationLabels.length > 0 ? relationLabels : undefined,
});
setHasEdgeChanges(false);
}, [selectedEdge.id, relationType, relationLabel, relationDirectionality, relationLabels, hasEdgeChanges, updateEdge]);
// Debounce live updates
useEffect(() => {
const timeoutId = setTimeout(() => {
if (hasEdgeChanges) {
updateEdgeProperties();
}
}, 500); // 500ms debounce
return () => clearTimeout(timeoutId);
}, [hasEdgeChanges, updateEdgeProperties]);
// Handle edge deletion
const handleDeleteEdge = async () => {
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();
}
};
// Handle reverse direction
const handleReverseDirection = () => {
// Push to history BEFORE mutation
pushToHistory('Reverse Relation Direction');
// Update the edges array with the reversed edge
const updatedEdges = edges.map(edge => {
if (edge.id === selectedEdge.id) {
return {
...edge,
source: edge.target,
target: edge.source,
sourceHandle: edge.targetHandle,
targetHandle: edge.sourceHandle,
};
}
return edge;
});
// Apply the update (setEdges is a pass-through without history tracking)
setEdges(updatedEdges);
};
// Handle edit relation type
const handleEditRelationType = () => {
if (!relationType) return;
setEditingRelationTypeId(relationType);
setShowRelationTypeModal(true);
};
// Handle close relation type modal
const handleCloseRelationTypeModal = () => {
setShowRelationTypeModal(false);
setEditingRelationTypeId(null);
};
// Get the current edge data from the store (to reflect live updates)
const currentEdge = edges.find(e => e.id === selectedEdge.id) || selectedEdge;
const selectedEdgeTypeConfig = edgeTypes.find((et) => et.id === relationType);
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 (
<>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
{/* Relation Type */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-xs font-medium text-gray-700">
Relation Type
</label>
<Tooltip title="Edit Relation Type">
<IconButton
size="small"
onClick={handleEditRelationType}
sx={{ padding: '2px' }}
>
<EditIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</div>
<select
value={relationType}
onChange={(e) => {
const newType = e.target.value;
setRelationType(newType);
// Apply relation type change instantly (no debounce)
updateEdge(selectedEdge.id, {
type: newType,
label: relationLabel.trim() || undefined,
directionality: relationDirectionality,
labels: relationLabels.length > 0 ? relationLabels : undefined,
});
}}
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>
{/* Labels */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Labels (optional)
</label>
<AutocompleteLabelSelector
value={relationLabels}
onChange={(newLabels) => {
setRelationLabels(newLabels);
setHasEdgeChanges(true);
}}
scope="relations"
/>
</div>
{/* Directionality */}
<div className="pt-3 border-t border-gray-200">
<label className="block text-xs font-medium text-gray-700 mb-2">
Directionality
</label>
<ToggleButtonGroup
value={relationDirectionality}
exclusive
onChange={(_, newValue) => {
if (newValue !== null) {
setRelationDirectionality(newValue);
// Apply directionality change instantly (no debounce)
updateEdge(selectedEdge.id, {
type: relationType,
label: relationLabel.trim() || undefined,
directionality: newValue,
labels: relationLabels.length > 0 ? relationLabels : undefined,
});
}
}}
size="small"
fullWidth
aria-label="relationship directionality"
>
<ToggleButton value="directed" aria-label="directed relationship">
<Tooltip title="Directed (one-way)">
<div className="flex items-center space-x-1">
<ArrowForwardIcon fontSize="small" />
<span className="text-xs">Directed</span>
</div>
</Tooltip>
</ToggleButton>
<ToggleButton value="bidirectional" aria-label="bidirectional relationship">
<Tooltip title="Bidirectional (two-way)">
<div className="flex items-center space-x-1">
<SyncAltIcon fontSize="small" />
<span className="text-xs">Both</span>
</div>
</Tooltip>
</ToggleButton>
<ToggleButton value="undirected" aria-label="undirected relationship">
<Tooltip title="Undirected (no direction)">
<div className="flex items-center space-x-1">
<RemoveIcon fontSize="small" />
<span className="text-xs">None</span>
</div>
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
</div>
{/* Connection Info with Reverse Direction */}
<div className="pt-3 border-t border-gray-200">
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-gray-700">
Connection
</label>
{relationDirectionality === 'directed' && (
<Tooltip title="Reverse Direction">
<IconButton size="small" onClick={handleReverseDirection}>
<SwapHorizIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</div>
<ConnectionDisplay
sourceNode={nodes.find(n => n.id === currentEdge.source)}
targetNode={nodes.find(n => n.id === currentEdge.target)}
nodeTypes={nodeTypes}
directionality={relationDirectionality}
/>
</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}
<EdgeTypeConfigModal
isOpen={showRelationTypeModal}
onClose={handleCloseRelationTypeModal}
initialEditingTypeId={editingRelationTypeId}
/>
</>
);
};
export default EdgeEditorPanel;

View file

@ -0,0 +1,13 @@
import GraphMetrics from '../Common/GraphMetrics';
import type { Actor, Relation } from '../../types';
interface GraphAnalysisPanelProps {
nodes: Actor[];
edges: Relation[];
}
const GraphAnalysisPanel = ({ nodes, edges }: GraphAnalysisPanelProps) => {
return <GraphMetrics nodes={nodes} edges={edges} />;
};
export default GraphAnalysisPanel;

View file

@ -0,0 +1,302 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { IconButton, Tooltip } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useConfirm } from '../../hooks/useConfirm';
import ConnectionDisplay from '../Common/ConnectionDisplay';
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
import type { Actor } from '../../types';
interface NodeEditorPanelProps {
selectedNode: Actor;
onClose: () => void;
}
const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
const { nodes, edges, nodeTypes, edgeTypes, updateNode, deleteNode } = useGraphWithHistory();
const { confirm, ConfirmDialogComponent } = useConfirm();
// Node property states
const [actorType, setActorType] = useState('');
const [actorLabel, setActorLabel] = useState('');
const [actorDescription, setActorDescription] = useState('');
const [actorLabels, setActorLabels] = useState<string[]>([]);
const labelInputRef = useRef<HTMLInputElement>(null);
// Track if user has made changes
const [hasNodeChanges, setHasNodeChanges] = useState(false);
// Actor type modal state
const [showActorTypeModal, setShowActorTypeModal] = useState(false);
const [editingActorTypeId, setEditingActorTypeId] = useState<string | null>(null);
// Update state when selected node changes
useEffect(() => {
setActorType(selectedNode.data?.type || '');
setActorLabel(selectedNode.data?.label || '');
setActorDescription(selectedNode.data?.description || '');
setActorLabels(selectedNode.data?.labels || []);
setHasNodeChanges(false);
// Focus and select the label input when node is selected
setTimeout(() => {
if (labelInputRef.current) {
labelInputRef.current.focus();
labelInputRef.current.select();
}
}, 100);
}, [selectedNode]);
// Live update node properties (debounced)
const updateNodeProperties = useCallback(() => {
if (!hasNodeChanges) return;
updateNode(selectedNode.id, {
data: {
type: actorType,
label: actorLabel,
description: actorDescription || undefined,
labels: actorLabels.length > 0 ? actorLabels : undefined,
},
});
setHasNodeChanges(false);
}, [selectedNode.id, actorType, actorLabel, actorDescription, actorLabels, hasNodeChanges, updateNode]);
// Debounce live updates
useEffect(() => {
const timeoutId = setTimeout(() => {
if (hasNodeChanges) {
updateNodeProperties();
}
}, 500); // 500ms debounce
return () => clearTimeout(timeoutId);
}, [hasNodeChanges, updateNodeProperties]);
// Handle node deletion
const handleDeleteNode = async () => {
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 edit actor type
const handleEditActorType = () => {
if (!actorType) return;
setEditingActorTypeId(actorType);
setShowActorTypeModal(true);
};
// Handle close actor type modal
const handleCloseActorTypeModal = () => {
setShowActorTypeModal(false);
setEditingActorTypeId(null);
};
// Get connections for selected node
const getNodeConnections = () => {
return edges.filter(
(edge) => edge.source === selectedNode.id || edge.target === selectedNode.id
);
};
const connections = getNodeConnections();
const selectedNodeTypeConfig = nodeTypes.find((nt) => nt.id === actorType);
return (
<>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
{/* Actor Type */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-xs font-medium text-gray-700">
Actor Type
</label>
<Tooltip title="Edit Actor Type">
<IconButton
size="small"
onClick={handleEditActorType}
sx={{ padding: '2px' }}
>
<EditIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</div>
<select
value={actorType}
onChange={(e) => {
const newType = e.target.value;
setActorType(newType);
// Apply actor type change instantly (no debounce)
updateNode(selectedNode.id, {
data: {
type: newType,
label: actorLabel,
description: actorDescription || undefined,
labels: actorLabels.length > 0 ? actorLabels : undefined,
},
});
}}
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>
{/* Labels */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Labels (optional)
</label>
<AutocompleteLabelSelector
value={actorLabels}
onChange={(newLabels) => {
setActorLabels(newLabels);
setHasNodeChanges(true);
}}
scope="actors"
/>
</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-3">
{connections.map((edge) => {
const edgeConfig = edgeTypes.find((et) => et.id === edge.data?.type);
const sourceNode = nodes.find(n => n.id === edge.source);
const targetNode = nodes.find(n => n.id === edge.target);
const edgeDirectionality = edge.data?.directionality || edgeConfig?.defaultDirectionality || 'directed';
return (
<div key={edge.id} className="space-y-1">
{/* Edge Type Badge */}
<div className="flex items-center space-x-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: edgeConfig?.color || '#6b7280' }}
/>
<span className="text-xs font-medium text-gray-700">
{edgeConfig?.label || 'Unknown'}
</span>
{edge.data?.label && edge.data.label !== edgeConfig?.label && (
<span className="text-xs text-gray-500">
({edge.data.label})
</span>
)}
</div>
{/* Connection Display */}
<ConnectionDisplay
sourceNode={sourceNode}
targetNode={targetNode}
nodeTypes={nodeTypes}
directionality={edgeDirectionality}
/>
</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}
<NodeTypeConfigModal
isOpen={showActorTypeModal}
onClose={handleCloseActorTypeModal}
initialEditingTypeId={editingActorTypeId}
/>
</>
);
};
export default NodeEditorPanel;

View file

@ -1,23 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { IconButton, Tooltip, ToggleButton, ToggleButtonGroup } from '@mui/material';
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 SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import SyncAltIcon from '@mui/icons-material/SyncAlt';
import RemoveIcon from '@mui/icons-material/Remove';
import EditIcon from '@mui/icons-material/Edit';
import { usePanelStore } from '../../stores/panelStore';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useDocumentHistory } from '../../hooks/useDocumentHistory';
import { useConfirm } from '../../hooks/useConfirm';
import GraphMetrics from '../Common/GraphMetrics';
import ConnectionDisplay from '../Common/ConnectionDisplay';
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
import type { Actor, Relation, EdgeDirectionality } from '../../types';
import NodeEditorPanel from './NodeEditorPanel';
import EdgeEditorPanel from './EdgeEditorPanel';
import GraphAnalysisPanel from './GraphAnalysisPanel';
import type { Actor, Relation } from '../../types';
/**
* RightPanel - Context-aware properties panel on the right side
@ -57,7 +46,6 @@ const PanelHeader = ({ title, onCollapse }: PanelHeaderProps) => (
);
const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
const {
rightPanelCollapsed,
rightPanelWidth,
@ -65,199 +53,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
expandRightPanel,
} = usePanelStore();
const { nodes, edges, nodeTypes, edgeTypes, updateNode, updateEdge, deleteNode, deleteEdge, setEdges } = useGraphWithHistory();
const { pushToHistory } = useDocumentHistory();
const { confirm, ConfirmDialogComponent } = useConfirm();
// Node property states
const [actorType, setActorType] = useState('');
const [actorLabel, setActorLabel] = useState('');
const [actorDescription, setActorDescription] = useState('');
const [actorLabels, setActorLabels] = useState<string[]>([]);
const labelInputRef = useRef<HTMLInputElement>(null);
// Edge property states
const [relationType, setRelationType] = useState('');
const [relationLabel, setRelationLabel] = useState('');
const [relationLabels, setRelationLabels] = useState<string[]>([]);
const [relationDirectionality, setRelationDirectionality] = useState<EdgeDirectionality>('directed');
// Track if user has made changes
const [hasNodeChanges, setHasNodeChanges] = useState(false);
const [hasEdgeChanges, setHasEdgeChanges] = useState(false);
// Actor type modal state
const [showActorTypeModal, setShowActorTypeModal] = useState(false);
const [editingActorTypeId, setEditingActorTypeId] = useState<string | null>(null);
// Relation type modal state
const [showRelationTypeModal, setShowRelationTypeModal] = useState(false);
const [editingRelationTypeId, setEditingRelationTypeId] = useState<string | null>(null);
// Update state when selected node changes
useEffect(() => {
if (selectedNode) {
setActorType(selectedNode.data?.type || '');
setActorLabel(selectedNode.data?.label || '');
setActorDescription(selectedNode.data?.description || '');
setActorLabels(selectedNode.data?.labels || []);
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) || '');
setRelationLabels(selectedEdge.data.labels || []);
const edgeTypeConfig = edgeTypes.find((et) => et.id === selectedEdge.data?.type);
setRelationDirectionality(selectedEdge.data.directionality || edgeTypeConfig?.defaultDirectionality || 'directed');
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,
labels: actorLabels.length > 0 ? actorLabels : undefined,
},
});
setHasNodeChanges(false);
}, [selectedNode, actorType, actorLabel, actorDescription, actorLabels, hasNodeChanges, updateNode]);
// Live update edge properties (debounced)
const updateEdgeProperties = useCallback(() => {
if (!selectedEdge || !hasEdgeChanges) return;
updateEdge(selectedEdge.id, {
type: relationType,
label: relationLabel.trim() || undefined,
directionality: relationDirectionality,
labels: relationLabels.length > 0 ? relationLabels : undefined,
});
setHasEdgeChanges(false);
}, [selectedEdge, relationType, relationLabel, relationDirectionality, relationLabels, 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();
}
};
// Handle reverse direction
const handleReverseDirection = () => {
if (!selectedEdge) return;
// Push to history BEFORE mutation
pushToHistory('Reverse Relation Direction');
// Update the edges array with the reversed edge
const updatedEdges = edges.map(edge => {
if (edge.id === selectedEdge.id) {
return {
...edge,
source: edge.target,
target: edge.source,
sourceHandle: edge.targetHandle,
targetHandle: edge.sourceHandle,
};
}
return edge;
});
// Apply the update (setEdges is a pass-through without history tracking)
setEdges(updatedEdges);
};
// Handle edit actor type
const handleEditActorType = () => {
if (!actorType) return;
setEditingActorTypeId(actorType);
setShowActorTypeModal(true);
};
// Handle close actor type modal
const handleCloseActorTypeModal = () => {
setShowActorTypeModal(false);
setEditingActorTypeId(null);
};
// Handle edit relation type
const handleEditRelationType = () => {
if (!relationType) return;
setEditingRelationTypeId(relationType);
setShowRelationTypeModal(true);
};
// Handle close relation type modal
const handleCloseRelationTypeModal = () => {
setShowRelationTypeModal(false);
setEditingRelationTypeId(null);
};
// 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);
const { nodes, edges } = useGraphWithHistory();
// Collapsed view
if (rightPanelCollapsed) {
@ -268,17 +64,6 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
<ChevronLeftIcon fontSize="small" />
</IconButton>
</Tooltip>
{ConfirmDialogComponent}
<NodeTypeConfigModal
isOpen={showActorTypeModal}
onClose={handleCloseActorTypeModal}
initialEditingTypeId={editingActorTypeId}
/>
<EdgeTypeConfigModal
isOpen={showRelationTypeModal}
onClose={handleCloseRelationTypeModal}
initialEditingTypeId={editingRelationTypeId}
/>
</div>
);
}
@ -291,455 +76,38 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
style={{ width: `${rightPanelWidth}px` }}
>
<PanelHeader title="Graph Analysis" onCollapse={collapseRightPanel} />
<GraphMetrics nodes={nodes} edges={edges} />
{ConfirmDialogComponent}
<NodeTypeConfigModal
isOpen={showActorTypeModal}
onClose={handleCloseActorTypeModal}
initialEditingTypeId={editingActorTypeId}
/>
<EdgeTypeConfigModal
isOpen={showRelationTypeModal}
onClose={handleCloseRelationTypeModal}
initialEditingTypeId={editingRelationTypeId}
/>
<GraphAnalysisPanel nodes={nodes} edges={edges} />
</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` }}
>
<PanelHeader title="Actor Properties" onCollapse={collapseRightPanel} />
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
{/* Actor Type */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-xs font-medium text-gray-700">
Actor Type
</label>
<Tooltip title="Edit Actor Type">
<IconButton
size="small"
onClick={handleEditActorType}
sx={{ padding: '2px' }}
>
<EditIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</div>
<select
value={actorType}
onChange={(e) => {
const newType = e.target.value;
setActorType(newType);
// Apply actor type change instantly (no debounce)
if (selectedNode) {
updateNode(selectedNode.id, {
data: {
type: newType,
label: actorLabel,
description: actorDescription || undefined,
labels: actorLabels.length > 0 ? actorLabels : undefined,
},
});
}
}}
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>
{/* Labels */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Labels (optional)
</label>
<AutocompleteLabelSelector
value={actorLabels}
onChange={(newLabels) => {
setActorLabels(newLabels);
setHasNodeChanges(true);
}}
scope="actors"
/>
</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-3">
{connections.map((edge) => {
const edgeConfig = edgeTypes.find((et) => et.id === edge.data?.type);
const sourceNode = nodes.find(n => n.id === edge.source);
const targetNode = nodes.find(n => n.id === edge.target);
const edgeDirectionality = edge.data?.directionality || edgeConfig?.defaultDirectionality || 'directed';
return (
<div key={edge.id} className="space-y-1">
{/* Edge Type Badge */}
<div className="flex items-center space-x-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: edgeConfig?.color || '#6b7280' }}
/>
<span className="text-xs font-medium text-gray-700">
{edgeConfig?.label || 'Unknown'}
</span>
{edge.data?.label && edge.data.label !== edgeConfig?.label && (
<span className="text-xs text-gray-500">
({edge.data.label})
</span>
)}
</div>
{/* Connection Display */}
<ConnectionDisplay
sourceNode={sourceNode}
targetNode={targetNode}
nodeTypes={nodeTypes}
directionality={edgeDirectionality}
/>
</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}
<NodeTypeConfigModal
isOpen={showActorTypeModal}
onClose={handleCloseActorTypeModal}
initialEditingTypeId={editingActorTypeId}
/>
<EdgeTypeConfigModal
isOpen={showRelationTypeModal}
onClose={handleCloseRelationTypeModal}
initialEditingTypeId={editingRelationTypeId}
/>
<NodeEditorPanel selectedNode={selectedNode} onClose={onClose} />
</div>
);
}
// Edge properties view
if (selectedEdge) {
// Get the current edge data from the store (to reflect live updates)
const currentEdge = edges.find(e => e.id === selectedEdge.id) || 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` }}
>
<PanelHeader title="Relation Properties" onCollapse={collapseRightPanel} />
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-4">
{/* Relation Type */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-xs font-medium text-gray-700">
Relation Type
</label>
<Tooltip title="Edit Relation Type">
<IconButton
size="small"
onClick={handleEditRelationType}
sx={{ padding: '2px' }}
>
<EditIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</div>
<select
value={relationType}
onChange={(e) => {
const newType = e.target.value;
setRelationType(newType);
// Apply relation type change instantly (no debounce)
if (selectedEdge) {
updateEdge(selectedEdge.id, {
type: newType,
label: relationLabel.trim() || undefined,
directionality: relationDirectionality,
labels: relationLabels.length > 0 ? relationLabels : undefined,
});
}
}}
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>
{/* Labels */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Labels (optional)
</label>
<AutocompleteLabelSelector
value={relationLabels}
onChange={(newLabels) => {
setRelationLabels(newLabels);
setHasEdgeChanges(true);
}}
scope="relations"
/>
</div>
{/* Directionality */}
<div className="pt-3 border-t border-gray-200">
<label className="block text-xs font-medium text-gray-700 mb-2">
Directionality
</label>
<ToggleButtonGroup
value={relationDirectionality}
exclusive
onChange={(_, newValue) => {
if (newValue !== null && selectedEdge) {
setRelationDirectionality(newValue);
// Apply directionality change instantly (no debounce)
updateEdge(selectedEdge.id, {
type: relationType,
label: relationLabel.trim() || undefined,
directionality: newValue,
labels: relationLabels.length > 0 ? relationLabels : undefined,
});
}
}}
size="small"
fullWidth
aria-label="relationship directionality"
>
<ToggleButton value="directed" aria-label="directed relationship">
<Tooltip title="Directed (one-way)">
<div className="flex items-center space-x-1">
<ArrowForwardIcon fontSize="small" />
<span className="text-xs">Directed</span>
</div>
</Tooltip>
</ToggleButton>
<ToggleButton value="bidirectional" aria-label="bidirectional relationship">
<Tooltip title="Bidirectional (two-way)">
<div className="flex items-center space-x-1">
<SyncAltIcon fontSize="small" />
<span className="text-xs">Both</span>
</div>
</Tooltip>
</ToggleButton>
<ToggleButton value="undirected" aria-label="undirected relationship">
<Tooltip title="Undirected (no direction)">
<div className="flex items-center space-x-1">
<RemoveIcon fontSize="small" />
<span className="text-xs">None</span>
</div>
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
</div>
{/* Connection Info with Reverse Direction */}
<div className="pt-3 border-t border-gray-200">
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-gray-700">
Connection
</label>
{relationDirectionality === 'directed' && (
<Tooltip title="Reverse Direction">
<IconButton size="small" onClick={handleReverseDirection}>
<SwapHorizIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</div>
<ConnectionDisplay
sourceNode={nodes.find(n => n.id === currentEdge.source)}
targetNode={nodes.find(n => n.id === currentEdge.target)}
nodeTypes={nodeTypes}
directionality={relationDirectionality}
/>
</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}
<EdgeTypeConfigModal
isOpen={showRelationTypeModal}
onClose={handleCloseRelationTypeModal}
initialEditingTypeId={editingRelationTypeId}
/>
<EdgeEditorPanel selectedEdge={selectedEdge} onClose={onClose} />
</div>
);
}
return (
<>
{ConfirmDialogComponent}
<NodeTypeConfigModal
isOpen={showActorTypeModal}
onClose={handleCloseActorTypeModal}
initialEditingTypeId={editingActorTypeId}
/>
<EdgeTypeConfigModal
isOpen={showRelationTypeModal}
onClose={handleCloseRelationTypeModal}
initialEditingTypeId={editingRelationTypeId}
/>
</>
);
return null;
};
export default RightPanel;