diff --git a/src/components/Common/AutocompleteLabelSelector.tsx b/src/components/Common/AutocompleteLabelSelector.tsx new file mode 100644 index 0000000..f1e25c6 --- /dev/null +++ b/src/components/Common/AutocompleteLabelSelector.tsx @@ -0,0 +1,258 @@ +import { useState, useRef, useEffect } from 'react'; +import { useGraphStore } from '../../stores/graphStore'; +import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; +import LabelBadge from './LabelBadge'; +import AddIcon from '@mui/icons-material/Add'; +import CloseIcon from '@mui/icons-material/Close'; + +/** + * AutocompleteLabelSelector - Text input with autocomplete dropdown for label selection + * + * Features: + * - Type to search/filter available labels + * - Quick create new label with typed name and random color + * - Multi-select with badge display + * - Keyboard navigation (arrow keys, enter, escape) + */ + +interface Props { + value: string[]; // Selected label IDs + onChange: (labelIds: string[]) => void; + scope: 'actors' | 'relations'; +} + +// Generate random pastel color for new labels +const generateRandomColor = () => { + const hue = Math.floor(Math.random() * 360); + return `hsl(${hue}, 70%, 65%)`; +}; + +const AutocompleteLabelSelector = ({ value, onChange, scope }: Props) => { + const labels = useGraphStore((state) => state.labels); + const { addLabel } = useGraphWithHistory(); + + const [inputValue, setInputValue] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(0); + + const inputRef = useRef(null); + const dropdownRef = useRef(null); + + // Filter labels by scope and search text + const availableLabels = labels.filter( + (label) => (label.appliesTo === scope || label.appliesTo === 'both') && !value.includes(label.id) + ); + + const filteredLabels = inputValue.trim() + ? availableLabels.filter((label) => + label.name.toLowerCase().includes(inputValue.toLowerCase()) + ) + : availableLabels; + + // Check if input matches an existing label exactly + const exactMatch = filteredLabels.find( + (label) => label.name.toLowerCase() === inputValue.trim().toLowerCase() + ); + + // Show "Create new" option if there's input text and no exact match + const showCreateOption = inputValue.trim() && !exactMatch; + + // Combined options: filtered labels + create option + const totalOptions = filteredLabels.length + (showCreateOption ? 1 : 0); + + // Get selected label configs + const selectedLabels = value + .map((id) => labels.find((l) => l.id === id)) + .filter((label): label is NonNullable => label !== undefined); + + // Handle selecting an existing label + const handleSelectLabel = (labelId: string) => { + if (!value.includes(labelId)) { + onChange([...value, labelId]); + } + setInputValue(''); + setIsOpen(false); + setHighlightedIndex(0); + }; + + // Handle creating a new label + const handleCreateLabel = () => { + const name = inputValue.trim(); + if (!name) return; + + // Generate label ID from name (same logic as LabelConfig) + const id = name.toLowerCase().replace(/\s+/g, '-'); + + // Check if ID already exists + if (labels.some((l) => l.id === id)) { + // If label already exists, just select it + onChange([...value, id]); + setInputValue(''); + setIsOpen(false); + setHighlightedIndex(0); + return; + } + + // Create new label (default to 'both' so it can be used anywhere) + const newLabel = { + id, + name, + color: generateRandomColor(), + appliesTo: 'both' as const, + }; + + addLabel(newLabel); + onChange([...value, id]); + setInputValue(''); + setIsOpen(false); + setHighlightedIndex(0); + }; + + // Handle removing a selected label + const handleRemoveLabel = (labelId: string) => { + onChange(value.filter((id) => id !== labelId)); + }; + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen && e.key !== 'Escape') { + setIsOpen(true); + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setHighlightedIndex((prev) => (prev + 1) % totalOptions); + break; + case 'ArrowUp': + e.preventDefault(); + setHighlightedIndex((prev) => (prev - 1 + totalOptions) % totalOptions); + break; + case 'Enter': + e.preventDefault(); + if (totalOptions > 0) { + if (showCreateOption && highlightedIndex === filteredLabels.length) { + handleCreateLabel(); + } else if (highlightedIndex < filteredLabels.length) { + handleSelectLabel(filteredLabels[highlightedIndex].id); + } + } + break; + case 'Escape': + e.preventDefault(); + setIsOpen(false); + setInputValue(''); + setHighlightedIndex(0); + break; + } + }; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Reset highlighted index when filtered options change + useEffect(() => { + setHighlightedIndex(0); + }, [inputValue]); + + return ( +
+ {/* Selected labels */} + {selectedLabels.length > 0 && ( +
+ {selectedLabels.map((label) => ( +
+ + +
+ ))} +
+ )} + + {/* Input field */} + { + setInputValue(e.target.value); + setIsOpen(true); + }} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + placeholder="Type to search or create labels..." + className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + {/* Dropdown */} + {isOpen && (filteredLabels.length > 0 || showCreateOption) && ( +
+ {/* Existing labels */} + {filteredLabels.map((label, index) => ( + + ))} + + {/* Create new label option */} + {showCreateOption && ( + + )} +
+ )} +
+ ); +}; + +export default AutocompleteLabelSelector; diff --git a/src/components/Common/LabelBadge.tsx b/src/components/Common/LabelBadge.tsx new file mode 100644 index 0000000..332b860 --- /dev/null +++ b/src/components/Common/LabelBadge.tsx @@ -0,0 +1,42 @@ +import { getContrastColor } from '../../utils/colorUtils'; + +/** + * LabelBadge - Displays a single label as a colored badge + * + * Features: + * - Pill-shaped design + * - Auto-contrast text color + * - Truncation with ellipsis + * - Tooltip on hover (via title attribute) + */ + +interface Props { + name: string; + color: string; + maxWidth?: string; + size?: 'sm' | 'md'; +} + +const LabelBadge = ({ name, color, maxWidth = '120px', size = 'sm' }: Props) => { + const textColor = getContrastColor(color); + + const sizeClasses = size === 'sm' + ? 'text-xs px-2 py-0.5' + : 'text-sm px-2.5 py-1'; + + return ( + + {name} + + ); +}; + +export default LabelBadge; diff --git a/src/components/Common/LabelSelector.tsx b/src/components/Common/LabelSelector.tsx new file mode 100644 index 0000000..3b441ec --- /dev/null +++ b/src/components/Common/LabelSelector.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react'; +import { useGraphStore } from '../../stores/graphStore'; +import LabelBadge from './LabelBadge'; + +/** + * LabelSelector - Multi-select dropdown for assigning labels to actors or relations + * + * Features: + * - Shows available labels filtered by scope (actors/relations/both) + * - Displays selected labels as badges with remove button + * - Checkbox interface for adding/removing labels + * - Empty state when no labels are configured + */ + +interface Props { + value: string[]; // Array of selected label IDs + onChange: (labelIds: string[]) => void; + scope: 'actors' | 'relations'; // Filter labels by scope +} + +const LabelSelector = ({ value, onChange, scope }: Props) => { + const labels = useGraphStore((state) => state.labels); + + // Filter labels by scope + const availableLabels = useMemo(() => { + return labels.filter( + (label) => label.appliesTo === scope || label.appliesTo === 'both' + ); + }, [labels, scope]); + + const selectedLabels = useMemo(() => { + return availableLabels.filter((label) => value.includes(label.id)); + }, [availableLabels, value]); + + const handleToggle = (labelId: string) => { + if (value.includes(labelId)) { + onChange(value.filter((id) => id !== labelId)); + } else { + onChange([...value, labelId]); + } + }; + + const handleRemove = (labelId: string) => { + onChange(value.filter((id) => id !== labelId)); + }; + + if (availableLabels.length === 0) { + return ( +
+ No labels configured. Configure labels in Edit → Configure Labels. +
+ ); + } + + return ( +
+ {/* Selected labels display */} + {selectedLabels.length > 0 && ( +
+ {selectedLabels.map((label) => ( +
+ + +
+ ))} +
+ )} + + {/* Label selection checkboxes */} +
+ {availableLabels.length === 0 ? ( +
+ No {scope} labels available +
+ ) : ( +
+ {availableLabels.map((label) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default LabelSelector; diff --git a/src/components/Config/EditLabelInline.tsx b/src/components/Config/EditLabelInline.tsx new file mode 100644 index 0000000..cd7ae5b --- /dev/null +++ b/src/components/Config/EditLabelInline.tsx @@ -0,0 +1,115 @@ +import { useState, useEffect, KeyboardEvent } from 'react'; +import SaveIcon from '@mui/icons-material/Save'; +import LabelForm from './LabelForm'; +import type { LabelConfig, LabelScope } from '../../types'; + +/** + * EditLabelInline - Inline edit view that replaces the right column + * + * Features: + * - Replaces management list in right column when editing + * - Reuses LabelForm + * - Save/Cancel actions + * - Keyboard accessible (Cmd/Ctrl+Enter to save, Escape to cancel) + */ + +interface Props { + label: LabelConfig; + onSave: ( + id: string, + updates: { + name: string; + color: string; + appliesTo: LabelScope; + description?: string; + } + ) => void; + onCancel: () => void; +} + +const EditLabelInline = ({ label, onSave, onCancel }: Props) => { + const [name, setName] = useState(''); + const [color, setColor] = useState('#6366f1'); + const [appliesTo, setAppliesTo] = useState('both'); + const [description, setDescription] = useState(''); + + // Sync state with label prop + useEffect(() => { + if (label) { + setName(label.name); + setColor(label.color); + setAppliesTo(label.appliesTo); + setDescription(label.description || ''); + } + }, [label]); + + const handleSave = () => { + if (!name.trim()) { + return; + } + + onSave(label.id, { + name: name.trim(), + color, + appliesTo, + description: description.trim() || undefined, + }); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + return ( +
+ {/* Form Fields */} +
+ +
+ + {/* Actions */} +
+
+ + +
+ + {/* Keyboard Shortcut Hint */} +
+ + {navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+Enter + {' '} + to save, Esc to cancel +
+
+
+ ); +}; + +export default EditLabelInline; diff --git a/src/components/Config/LabelConfig.tsx b/src/components/Config/LabelConfig.tsx new file mode 100644 index 0000000..21abe46 --- /dev/null +++ b/src/components/Config/LabelConfig.tsx @@ -0,0 +1,234 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; +import { useConfirm } from '../../hooks/useConfirm'; +import { useToastStore } from '../../stores/toastStore'; +import QuickAddLabelForm from './QuickAddLabelForm'; +import LabelManagementList from './LabelManagementList'; +import EditLabelInline from './EditLabelInline'; +import type { LabelConfig as LabelConfigType, LabelScope } from '../../types'; + +/** + * LabelConfig - Modal for managing labels + * + * Features: + * - Two-column layout: quick add (left) + management/edit (right) + * - Inline editing replaces right column + * - Usage count calculation + * - Toast notifications for actions + * - Full keyboard accessibility + */ + +interface Props { + isOpen: boolean; + onClose: () => void; + initialEditingLabelId?: string | null; +} + +const LabelConfigModal = ({ isOpen, onClose, initialEditingLabelId }: Props) => { + const { labels, nodes, edges, addLabel, updateLabel, deleteLabel } = useGraphWithHistory(); + const { confirm, ConfirmDialogComponent } = useConfirm(); + const { showToast } = useToastStore(); + + const [editingLabel, setEditingLabel] = useState(null); + + // Calculate usage counts for each label + const usageCounts = useMemo(() => { + const counts: Record = {}; + + // Initialize counts + labels.forEach((label) => { + counts[label.id] = { actors: 0, relations: 0 }; + }); + + // Count actor usage + nodes.forEach((node) => { + node.data.labels?.forEach((labelId) => { + if (counts[labelId]) { + counts[labelId].actors++; + } + }); + }); + + // Count relation usage + edges.forEach((edge) => { + edge.data?.labels?.forEach((labelId) => { + if (counts[labelId]) { + counts[labelId].relations++; + } + }); + }); + + return counts; + }, [labels, nodes, edges]); + + // Set editing label when initialEditingLabelId changes + useEffect(() => { + if (initialEditingLabelId && isOpen) { + const labelToEdit = labels.find((l) => l.id === initialEditingLabelId); + if (labelToEdit) { + setEditingLabel(labelToEdit); + } + } else if (!isOpen) { + // Clear editing label when modal closes + setEditingLabel(null); + } + }, [initialEditingLabelId, isOpen, labels]); + + const handleAddLabel = (label: { + name: string; + color: string; + appliesTo: LabelScope; + description: string; + }) => { + const id = label.name.toLowerCase().replace(/\s+/g, '-'); + + // Check if ID already exists (case-insensitive) + if (labels.some((l) => l.id === id)) { + showToast('A label with this name already exists', 'error'); + return; + } + + const newLabel: LabelConfigType = { + id, + name: label.name, + color: label.color, + appliesTo: label.appliesTo, + description: label.description || undefined, + }; + + addLabel(newLabel); + showToast(`Label "${label.name}" created`, 'success'); + }; + + const handleDeleteLabel = async (id: string) => { + const label = labels.find((l) => l.id === id); + const usage = usageCounts[id] || { actors: 0, relations: 0 }; + const totalUsage = usage.actors + usage.relations; + + let message = 'Are you sure you want to delete this label?'; + if (totalUsage > 0) { + message = `This label is used by ${totalUsage} item${totalUsage !== 1 ? 's' : ''}. Deleting it will remove it from all actors and relations. This action cannot be undone.`; + } + + const confirmed = await confirm({ + title: 'Delete Label', + message, + confirmLabel: 'Delete', + severity: 'danger', + }); + + if (confirmed) { + deleteLabel(id); + showToast(`Label "${label?.name}" deleted`, 'success'); + } + }; + + const handleEditLabel = (label: LabelConfigType) => { + setEditingLabel(label); + }; + + const handleSaveEdit = ( + id: string, + updates: { name: string; color: string; appliesTo: LabelScope; description?: string } + ) => { + updateLabel(id, updates); + setEditingLabel(null); + showToast(`Label "${updates.name}" updated`, 'success'); + }; + + const handleCancelEdit = () => { + setEditingLabel(null); + }; + + if (!isOpen) return null; + + return ( + <> + {/* Main Modal */} +
+
+ {/* Header */} +
+

Configure Labels

+

+ Create and manage labels to categorize actors and relations +

+
+ + {/* Content - Two-Column or Full-Width Edit */} +
+ {editingLabel ? ( + /* Full-Width Edit Mode */ +
+
+ +
+
+ ) : ( + <> + {/* Left Column - Quick Add (60%) */} +
+
+

+ Quick Add Label +

+ +
+ + {/* Helper Text */} +
+

About Labels

+
    +
  • • Labels help you categorize and filter actors and relations
  • +
  • • Apply multiple labels to any item for flexible organization
  • +
  • • Labels can apply to actors only, relations only, or both
  • +
  • • Use the filter panel to show/hide items by label
  • +
+
+
+ + {/* Right Column - Management (40%) */} +
+
+
+

+ Labels ({labels.length}) +

+
+ +
+
+ + )} +
+ + {/* Footer - Hidden when editing */} + {!editingLabel && ( +
+ +
+ )} +
+
+ + {/* Confirmation Dialog */} + {ConfirmDialogComponent} + + ); +}; + +export default LabelConfigModal; diff --git a/src/components/Config/LabelForm.tsx b/src/components/Config/LabelForm.tsx new file mode 100644 index 0000000..db374d5 --- /dev/null +++ b/src/components/Config/LabelForm.tsx @@ -0,0 +1,127 @@ +import type { LabelScope } from '../../types'; + +/** + * LabelForm - Reusable form fields for creating/editing labels + * + * Features: + * - Name input + * - Color picker (visual + text input) + * - Scope selector (actors/relations/both) + * - Description input + */ + +interface Props { + name: string; + color: string; + appliesTo: LabelScope; + description: string; + onNameChange: (value: string) => void; + onColorChange: (value: string) => void; + onAppliesToChange: (value: LabelScope) => void; + onDescriptionChange: (value: string) => void; +} + +const LabelForm = ({ + name, + color, + appliesTo, + description, + onNameChange, + onColorChange, + onAppliesToChange, + onDescriptionChange, +}: Props) => { + return ( +
+
+ + onNameChange(e.target.value)} + placeholder="e.g., Team, Lead, Important" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ +
+ onColorChange(e.target.value)} + className="h-10 w-20 rounded cursor-pointer" + /> + onColorChange(e.target.value)} + placeholder="#6366f1" + className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ +
+ +
+ + + +
+
+ +
+ + onDescriptionChange(e.target.value)} + placeholder="Brief description of this label" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ ); +}; + +export default LabelForm; diff --git a/src/components/Config/LabelManagementList.tsx b/src/components/Config/LabelManagementList.tsx new file mode 100644 index 0000000..d5e0074 --- /dev/null +++ b/src/components/Config/LabelManagementList.tsx @@ -0,0 +1,128 @@ +import DeleteIcon from '@mui/icons-material/Delete'; +import type { LabelConfig } from '../../types'; + +/** + * LabelManagementList - Compact list view for managing existing labels + * + * Features: + * - White background cards with click to edit + * - Color badge + name + scope + usage count + * - Delete button + * - Keyboard accessible + * - ARIA compliant + */ + +interface Props { + labels: LabelConfig[]; + usageCounts: Record; + onEdit: (label: LabelConfig) => void; + onDelete: (id: string) => void; +} + +const LabelManagementList = ({ labels, usageCounts, onEdit, onDelete }: Props) => { + if (labels.length === 0) { + return ( +
+

No labels yet.

+

Add your first label above.

+
+ ); + } + + const getScopeLabel = (appliesTo: string) => { + switch (appliesTo) { + case 'actors': + return 'Actors'; + case 'relations': + return 'Relations'; + case 'both': + return 'Both'; + default: + return 'Unknown'; + } + }; + + const getUsageText = (labelId: string) => { + const usage = usageCounts[labelId] || { actors: 0, relations: 0 }; + const total = usage.actors + usage.relations; + + if (total === 0) return 'Not used'; + + const parts = []; + if (usage.actors > 0) parts.push(`${usage.actors} actor${usage.actors !== 1 ? 's' : ''}`); + if (usage.relations > 0) parts.push(`${usage.relations} relation${usage.relations !== 1 ? 's' : ''}`); + + return parts.join(', '); + }; + + return ( +
+ {labels.map((label) => ( +
onEdit(label)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onEdit(label); + } + }} + aria-label={`Edit ${label.name}`} + > +
+ {/* Label Info */} +
+ {/* Color Badge */} + + + {/* Actions */} +
+ +
+
+
+ ))} +
+ ); +}; + +export default LabelManagementList; diff --git a/src/components/Config/QuickAddLabelForm.tsx b/src/components/Config/QuickAddLabelForm.tsx new file mode 100644 index 0000000..f96fbeb --- /dev/null +++ b/src/components/Config/QuickAddLabelForm.tsx @@ -0,0 +1,95 @@ +import { useState, useRef, KeyboardEvent } from 'react'; +import LabelForm from './LabelForm'; +import type { LabelScope } from '../../types'; + +/** + * QuickAddLabelForm - Streamlined form for quickly adding new labels + * + * Features: + * - Quick add with name, color, and scope + * - Keyboard accessible (Enter to submit, Escape to cancel) + * - Focus management + */ + +interface Props { + onAdd: (label: { + name: string; + color: string; + appliesTo: LabelScope; + description: string; + }) => void; +} + +const QuickAddLabelForm = ({ onAdd }: Props) => { + const [name, setName] = useState(''); + const [color, setColor] = useState('#6366f1'); + const [appliesTo, setAppliesTo] = useState('both'); + const [description, setDescription] = useState(''); + + const nameInputRef = useRef(null); + + const handleSubmit = () => { + if (!name.trim()) { + nameInputRef.current?.focus(); + return; + } + + onAdd({ name: name.trim(), color, appliesTo, description }); + + // Reset form + setName(''); + setColor('#6366f1'); + setAppliesTo('both'); + setDescription(''); + + // Focus back to name input for quick subsequent additions + nameInputRef.current?.focus(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + // Reset form + setName(''); + setColor('#6366f1'); + setAppliesTo('both'); + setDescription(''); + nameInputRef.current?.blur(); + } + }; + + return ( +
+ + + + + {/* Keyboard Shortcuts Hint */} + {name && ( +
+ Press Enter to add, Escape to cancel +
+ )} +
+ ); +}; + +export default QuickAddLabelForm; diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index 8990801..3cd5627 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -8,6 +8,7 @@ import { import { useGraphStore } from '../../stores/graphStore'; import { useSearchStore } from '../../stores/searchStore'; import type { RelationData } from '../../types'; +import LabelBadge from '../Common/LabelBadge'; /** * CustomEdge - Represents a relation between actors in the constellation graph @@ -33,7 +34,8 @@ const CustomEdge = ({ selected, }: EdgeProps) => { const edgeTypes = useGraphStore((state) => state.edgeTypes); - const { searchText, visibleRelationTypes } = useSearchStore(); + const labels = useGraphStore((state) => state.labels); + const { searchText, selectedRelationTypes, selectedLabels } = useSearchStore(); // Calculate the bezier path const [edgePath, labelX, labelY] = getBezierPath({ @@ -65,11 +67,23 @@ const CustomEdge = ({ // Check if this edge matches the filter criteria const isMatch = useMemo(() => { - // Check type visibility + // Check relation type filter (POSITIVE: if types selected, edge must be one of them) const edgeType = data?.type || ''; - const isTypeVisible = visibleRelationTypes[edgeType] !== false; - if (!isTypeVisible) { - return false; + if (selectedRelationTypes.length > 0) { + if (!selectedRelationTypes.includes(edgeType)) { + return false; + } + } + + // Check label filter (POSITIVE: if labels selected, edge must have at least one) + if (selectedLabels.length > 0) { + const edgeLabels = data?.labels || []; + const hasSelectedLabel = edgeLabels.some((labelId) => + selectedLabels.includes(labelId) + ); + if (!hasSelectedLabel) { + return false; + } } // Check search text match @@ -82,12 +96,13 @@ const CustomEdge = ({ } return true; - }, [searchText, visibleRelationTypes, data?.type, data?.label, edgeTypeConfig?.label]); + }, [searchText, selectedRelationTypes, selectedLabels, data?.type, data?.label, data?.labels, edgeTypeConfig?.label]); // Determine if filters are active const hasActiveFilters = searchText.trim() !== '' || - Object.values(visibleRelationTypes).some(v => v === false); + selectedRelationTypes.length > 0 || + selectedLabels.length > 0; // Calculate opacity based on visibility const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0; @@ -151,8 +166,8 @@ const CustomEdge = ({ markerStart={markerStart} /> - {/* Edge label - show custom or type default */} - {displayLabel && ( + {/* Edge label - show custom or type default, plus labels */} + {(displayLabel || (data?.labels && data.labels.length > 0)) && (
-
{displayLabel}
+ {displayLabel && ( +
+ {displayLabel} +
+ )} + {data?.labels && data.labels.length > 0 && ( +
+ {data.labels.map((labelId) => { + const labelConfig = labels.find((l) => l.id === labelId); + if (!labelConfig) return null; + return ( + + ); + })} +
+ )}
)} diff --git a/src/components/Editor/EdgePropertiesPanel.tsx b/src/components/Editor/EdgePropertiesPanel.tsx index 1a7eb0a..618d033 100644 --- a/src/components/Editor/EdgePropertiesPanel.tsx +++ b/src/components/Editor/EdgePropertiesPanel.tsx @@ -1,6 +1,7 @@ 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'; /** @@ -22,6 +23,7 @@ const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => { const { edgeTypes, updateEdge, deleteEdge } = useGraphWithHistory(); const [relationType, setRelationType] = useState(''); const [relationLabel, setRelationLabel] = useState(''); + const [relationLabels, setRelationLabels] = useState([]); useEffect(() => { if (selectedEdge && selectedEdge.data) { @@ -30,6 +32,7 @@ const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => { 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]); @@ -39,6 +42,7 @@ const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => { type: relationType, // Only set label if user provided a custom one (not empty) label: relationLabel.trim() || undefined, + labels: relationLabels.length > 0 ? relationLabels : undefined, }); onClose(); }; @@ -121,6 +125,18 @@ const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => {

+ {/* Labels */} +
+ + +
+ {/* Connection Info */} {selectedEdge && (
diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 8cbde57..1f3aedf 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -107,8 +107,9 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq // Search and filter state for auto-zoom const { searchText, - visibleActorTypes, - visibleRelationTypes, + selectedActorTypes, + selectedRelationTypes, + selectedLabels, } = useSearchStore(); // Settings for auto-zoom @@ -260,12 +261,11 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq // Check if any filters are active const hasSearchText = searchText.trim() !== ''; - const hasTypeFilters = - Object.values(visibleActorTypes).some(v => v === false) || - Object.values(visibleRelationTypes).some(v => v === false); + const hasTypeFilters = selectedActorTypes.length > 0 || selectedRelationTypes.length > 0; + const hasLabelFilters = selectedLabels.length > 0; // Skip if no filters are active - if (!hasSearchText && !hasTypeFilters) return; + if (!hasSearchText && !hasTypeFilters && !hasLabelFilters) return; // Debounce to avoid excessive viewport changes while typing const timeoutId = setTimeout(() => { @@ -277,10 +277,22 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq const actor = node as Actor; const actorType = actor.data?.type || ''; - // Filter by actor type visibility - const isTypeVisible = visibleActorTypes[actorType] !== false; - if (!isTypeVisible) { - return false; + // Filter by actor type (POSITIVE: if types selected, node must be one of them) + if (selectedActorTypes.length > 0) { + if (!selectedActorTypes.includes(actorType)) { + return false; + } + } + + // Filter by label (POSITIVE: if labels selected, node must have at least one) + if (selectedLabels.length > 0) { + const nodeLabels = actor.data?.labels || []; + const hasSelectedLabel = nodeLabels.some((labelId) => + selectedLabels.includes(labelId) + ); + if (!hasSelectedLabel) { + return false; + } } // Filter by search text @@ -319,8 +331,9 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq return () => clearTimeout(timeoutId); }, [ searchText, - visibleActorTypes, - visibleRelationTypes, + selectedActorTypes, + selectedRelationTypes, + selectedLabels, autoZoomEnabled, nodes, nodeTypeConfigs, @@ -631,12 +644,16 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq [nodeTypeConfigs, addNodeWithHistory], ); - // Call the onAddNodeRequest callback if provided + // Store callbacks in refs and call parent callbacks only once on mount + const handleAddNodeRef = useRef(handleAddNode); + handleAddNodeRef.current = handleAddNode; + useEffect(() => { if (onAddNodeRequest) { - onAddNodeRequest(handleAddNode); + onAddNodeRequest((...args) => handleAddNodeRef.current(...args)); } - }, [onAddNodeRequest, handleAddNode]); + + }, [onAddNodeRequest]); // Only run when onAddNodeRequest changes // Provide export callback to parent const handleExport = useCallback( @@ -650,11 +667,15 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq [exportPNG, exportSVG] ); + const handleExportRef = useRef(handleExport); + handleExportRef.current = handleExport; + useEffect(() => { if (onExportRequest) { - onExportRequest(handleExport); + onExportRequest((...args) => handleExportRef.current(...args)); } - }, [onExportRequest, handleExport]); + + }, [onExportRequest]); // Only run when onExportRequest changes // Add new actor at context menu position const handleAddActorFromContextMenu = useCallback( diff --git a/src/components/Editor/NodePropertiesPanel.tsx b/src/components/Editor/NodePropertiesPanel.tsx index 3400905..319e549 100644 --- a/src/components/Editor/NodePropertiesPanel.tsx +++ b/src/components/Editor/NodePropertiesPanel.tsx @@ -1,6 +1,7 @@ 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'; /** @@ -24,6 +25,7 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => { const [actorType, setActorType] = useState(''); const [actorLabel, setActorLabel] = useState(''); const [actorDescription, setActorDescription] = useState(''); + const [actorLabels, setActorLabels] = useState([]); const labelInputRef = useRef(null); useEffect(() => { @@ -31,6 +33,7 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => { 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(() => { @@ -49,6 +52,7 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => { type: actorType, label: actorLabel, description: actorDescription || undefined, + labels: actorLabels.length > 0 ? actorLabels : undefined, }, }); onClose(); @@ -130,6 +134,18 @@ const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => { />
+ {/* Labels */} +
+ + +
+ {/* Node Info */} {selectedNode && (
diff --git a/src/components/Menu/MenuBar.tsx b/src/components/Menu/MenuBar.tsx index 565e693..2684b76 100644 --- a/src/components/Menu/MenuBar.tsx +++ b/src/components/Menu/MenuBar.tsx @@ -6,6 +6,7 @@ import { useDocumentHistory } from '../../hooks/useDocumentHistory'; import DocumentManager from '../Workspace/DocumentManager'; import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; +import LabelConfigModal from '../Config/LabelConfig'; import InputDialog from '../Common/InputDialog'; import { useConfirm } from '../../hooks/useConfirm'; import { useShortcutLabels } from '../../hooks/useShortcutLabels'; @@ -32,6 +33,7 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) => const [showDocumentManager, setShowDocumentManager] = useState(false); const [showNodeConfig, setShowNodeConfig] = useState(false); const [showEdgeConfig, setShowEdgeConfig] = useState(false); + const [showLabelConfig, setShowLabelConfig] = useState(false); const [showNewDocDialog, setShowNewDocDialog] = useState(false); const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false); const menuRef = useRef(null); @@ -173,6 +175,11 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) => closeMenu(); }, [closeMenu]); + const handleConfigureLabels = useCallback(() => { + setShowLabelConfig(true); + closeMenu(); + }, [closeMenu]); + const handleUndo = useCallback(() => { undo(); closeMenu(); @@ -373,6 +380,12 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) => > Configure Relation Types +
@@ -465,6 +478,10 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) => isOpen={showEdgeConfig} onClose={() => setShowEdgeConfig(false)} /> + setShowLabelConfig(false)} + /> {/* Input Dialogs */} ) => { const nodeTypes = useGraphStore((state) => state.nodeTypes); - const { searchText, visibleActorTypes } = useSearchStore(); + const labels = useGraphStore((state) => state.labels); + const { searchText, selectedActorTypes, selectedLabels } = useSearchStore(); // Check if any connection is being made (to show handles) const connectionNodeId = useStore((state) => state.connectionNodeId); @@ -47,10 +49,22 @@ const CustomNode = ({ data, selected }: NodeProps) => { // Check if this node matches the search and filter criteria const isMatch = useMemo(() => { - // Check type visibility - const isTypeVisible = visibleActorTypes[data.type] !== false; - if (!isTypeVisible) { - return false; + // Check actor type filter (POSITIVE: if types selected, node must be one of them) + if (selectedActorTypes.length > 0) { + if (!selectedActorTypes.includes(data.type)) { + return false; + } + } + + // Check label filter (POSITIVE: if labels selected, node must have at least one) + if (selectedLabels.length > 0) { + const nodeLabels = data.labels || []; + const hasSelectedLabel = nodeLabels.some((labelId) => + selectedLabels.includes(labelId) + ); + if (!hasSelectedLabel) { + return false; + } } // Check search text match @@ -70,9 +84,11 @@ const CustomNode = ({ data, selected }: NodeProps) => { return true; }, [ searchText, - visibleActorTypes, + selectedActorTypes, + selectedLabels, data.type, data.label, + data.labels, data.description, nodeLabel, ]); @@ -80,7 +96,8 @@ const CustomNode = ({ data, selected }: NodeProps) => { // Determine if filters are active const hasActiveFilters = searchText.trim() !== "" || - Object.values(visibleActorTypes).some((v) => v === false); + selectedActorTypes.length > 0 || + selectedLabels.length > 0; // Calculate opacity based on match status const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0; @@ -189,6 +206,25 @@ const CustomNode = ({ data, selected }: NodeProps) => { > {nodeLabel}
+ + {/* Labels */} + {data.labels && data.labels.length > 0 && ( +
+ {data.labels.map((labelId) => { + const labelConfig = labels.find((l) => l.id === labelId); + if (!labelConfig) return null; + return ( + + ); + })} +
+ )} diff --git a/src/components/Panels/LeftPanel.tsx b/src/components/Panels/LeftPanel.tsx index 1c2d996..301cf54 100644 --- a/src/components/Panels/LeftPanel.tsx +++ b/src/components/Panels/LeftPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, useMemo, useEffect, useRef, useImperativeHandle, forwardRef } from 'react'; +import { useCallback, useState, useMemo, useRef, useImperativeHandle, forwardRef } from 'react'; import { IconButton, Tooltip, Checkbox } from '@mui/material'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; @@ -19,6 +19,7 @@ import { getIconComponent } from '../../utils/iconUtils'; import { getContrastColor } from '../../utils/colorUtils'; import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; +import LabelBadge from '../Common/LabelBadge'; import type { Actor } from '../../types'; /** @@ -52,7 +53,7 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA expandLeftPanel, } = usePanelStore(); - const { nodeTypes, edgeTypes, addNode, nodes, edges } = useGraphWithHistory(); + const { nodeTypes, edgeTypes, labels, addNode, nodes, edges } = useGraphWithHistory(); const { selectedRelationType, setSelectedRelationType } = useEditorStore(); const [showNodeConfig, setShowNodeConfig] = useState(false); const [showEdgeConfig, setShowEdgeConfig] = useState(false); @@ -84,10 +85,12 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA const { searchText, setSearchText, - visibleActorTypes, - setActorTypeVisible, - visibleRelationTypes, - setRelationTypeVisible, + selectedActorTypes, + toggleSelectedActorType, + selectedRelationTypes, + toggleSelectedRelationType, + selectedLabels, + toggleSelectedLabel, clearFilters, hasActiveFilters, } = useSearchStore(); @@ -95,22 +98,7 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA // Settings const { autoZoomEnabled, setAutoZoomEnabled } = useSettingsStore(); - // Initialize filter state when node/edge types change - useEffect(() => { - nodeTypes.forEach((nodeType) => { - if (!(nodeType.id in visibleActorTypes)) { - setActorTypeVisible(nodeType.id, true); - } - }); - }, [nodeTypes, visibleActorTypes, setActorTypeVisible]); - - useEffect(() => { - edgeTypes.forEach((edgeType) => { - if (!(edgeType.id in visibleRelationTypes)) { - setRelationTypeVisible(edgeType.id, true); - } - }); - }, [edgeTypes, visibleRelationTypes, setRelationTypeVisible]); + // No need to initialize filter state - all filters are positive (empty = show all) // Calculate matching nodes based on search and filters const matchingNodes = useMemo(() => { @@ -120,10 +108,22 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA const actor = node as Actor; const actorType = actor.data?.type || ''; - // Filter by actor type visibility - const isTypeVisible = visibleActorTypes[actorType] !== false; - if (!isTypeVisible) { - return false; + // Filter by actor type (POSITIVE: if types selected, node must be one of them) + if (selectedActorTypes.length > 0) { + if (!selectedActorTypes.includes(actorType)) { + return false; + } + } + + // Filter by label (POSITIVE: if labels selected, node must have at least one) + if (selectedLabels.length > 0) { + const nodeLabels = actor.data?.labels || []; + const hasSelectedLabel = nodeLabels.some((labelId) => + selectedLabels.includes(labelId) + ); + if (!hasSelectedLabel) { + return false; + } } // Filter by search text @@ -145,7 +145,7 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA return true; }); - }, [nodes, searchText, visibleActorTypes, nodeTypes]); + }, [nodes, searchText, selectedActorTypes, selectedLabels, nodeTypes]); // Calculate matching edges based on search and filters const matchingEdges = useMemo(() => { @@ -154,10 +154,22 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA return edges.filter((edge) => { const edgeType = edge.data?.type || ''; - // Filter by edge type visibility - const isTypeVisible = visibleRelationTypes[edgeType] !== false; - if (!isTypeVisible) { - return false; + // Filter by relation type (POSITIVE: if types selected, edge must be one of them) + if (selectedRelationTypes.length > 0) { + if (!selectedRelationTypes.includes(edgeType)) { + return false; + } + } + + // Filter by label (POSITIVE: if labels selected, edge must have at least one) + if (selectedLabels.length > 0) { + const edgeLabels = edge.data?.labels || []; + const hasSelectedLabel = edgeLabels.some((labelId) => + selectedLabels.includes(labelId) + ); + if (!hasSelectedLabel) { + return false; + } } // Filter by search text @@ -177,7 +189,7 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA return true; }); - }, [edges, searchText, visibleRelationTypes, edgeTypes]); + }, [edges, searchText, selectedRelationTypes, selectedLabels, edgeTypes]); const handleAddNode = useCallback( @@ -464,10 +476,13 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA
{nodeTypes.map((nodeType) => { - const isVisible = visibleActorTypes[nodeType.id] !== false; + const isSelected = selectedActorTypes.includes(nodeType.id); const IconComponent = getIconComponent(nodeType.icon); return ( @@ -476,8 +491,8 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA className="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 px-2 py-1 rounded transition-colors" > setActorTypeVisible(nodeType.id, !isVisible)} + checked={isSelected} + onChange={() => toggleSelectedActorType(nodeType.id)} size="small" sx={{ padding: '2px' }} /> @@ -501,16 +516,29 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA ); })}
+ {selectedActorTypes.length === 0 && ( +

+ No types selected - showing all actors +

+ )} + {selectedActorTypes.length > 0 && ( +

+ Showing only selected actor types +

+ )}
{/* Filter by Relation */}
{edgeTypes.map((edgeType) => { - const isVisible = visibleRelationTypes[edgeType.id] !== false; + const isSelected = selectedRelationTypes.includes(edgeType.id); return (
+ {selectedRelationTypes.length === 0 && ( +

+ No types selected - showing all relations +

+ )} + {selectedRelationTypes.length > 0 && ( +

+ Showing only selected relation types +

+ )}
+ {/* Filter by Label */} + {labels.length > 0 && ( +
+ +
+ {labels.map((label) => { + const isSelected = selectedLabels.includes(label.id); + + return ( + + ); + })} +
+ {selectedLabels.length === 0 && ( +

+ No labels selected - showing all items +

+ )} + {selectedLabels.length > 0 && ( +

+ Showing only items with selected labels +

+ )} +
+ )} + {/* Results Summary */}
diff --git a/src/components/Panels/RightPanel.tsx b/src/components/Panels/RightPanel.tsx index a1bb0d6..c8c2e23 100644 --- a/src/components/Panels/RightPanel.tsx +++ b/src/components/Panels/RightPanel.tsx @@ -16,6 +16,7 @@ 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'; /** @@ -72,11 +73,13 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => { const [actorType, setActorType] = useState(''); const [actorLabel, setActorLabel] = useState(''); const [actorDescription, setActorDescription] = useState(''); + const [actorLabels, setActorLabels] = useState([]); const labelInputRef = useRef(null); // Edge property states const [relationType, setRelationType] = useState(''); const [relationLabel, setRelationLabel] = useState(''); + const [relationLabels, setRelationLabels] = useState([]); const [relationDirectionality, setRelationDirectionality] = useState('directed'); // Track if user has made changes @@ -97,6 +100,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => { 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 @@ -116,6 +120,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => { 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); @@ -130,10 +135,11 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => { type: actorType, label: actorLabel, description: actorDescription || undefined, + labels: actorLabels.length > 0 ? actorLabels : undefined, }, }); setHasNodeChanges(false); - }, [selectedNode, actorType, actorLabel, actorDescription, hasNodeChanges, updateNode]); + }, [selectedNode, actorType, actorLabel, actorDescription, actorLabels, hasNodeChanges, updateNode]); // Live update edge properties (debounced) const updateEdgeProperties = useCallback(() => { @@ -142,9 +148,10 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => { type: relationType, label: relationLabel.trim() || undefined, directionality: relationDirectionality, + labels: relationLabels.length > 0 ? relationLabels : undefined, }); setHasEdgeChanges(false); - }, [selectedEdge, relationType, relationLabel, relationDirectionality, hasEdgeChanges, updateEdge]); + }, [selectedEdge, relationType, relationLabel, relationDirectionality, relationLabels, hasEdgeChanges, updateEdge]); // Debounce live updates useEffect(() => { @@ -341,6 +348,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => { type: newType, label: actorLabel, description: actorDescription || undefined, + labels: actorLabels.length > 0 ? actorLabels : undefined, }, }); } @@ -401,6 +409,21 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => { />
+ {/* Labels */} +
+ + { + setActorLabels(newLabels); + setHasNodeChanges(true); + }} + scope="actors" + /> +
+ {/* Connections */}

@@ -553,6 +576,7 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => { type: newType, label: relationLabel.trim() || undefined, directionality: relationDirectionality, + labels: relationLabels.length > 0 ? relationLabels : undefined, }); } }} @@ -587,6 +611,21 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {

+ {/* Labels */} +
+ + { + setRelationLabels(newLabels); + setHasEdgeChanges(true); + }} + scope="relations" + /> +
+ {/* Directionality */}