Redesigned Edge Styles via New Context Menu Panel

This commit is contained in:
Nicole Rappe 2025-05-20 05:58:31 -06:00
parent aa1d12b3e3
commit 8cf209c878
2 changed files with 530 additions and 339 deletions

View File

@ -0,0 +1,415 @@
import React, { useState, useEffect } from "react";
import { Box, Typography, Tabs, Tab, TextField, MenuItem, Button, Slider, IconButton, Tooltip } from "@mui/material";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import ContentPasteIcon from "@mui/icons-material/ContentPaste";
import RestoreIcon from "@mui/icons-material/Restore";
import { SketchPicker } from "react-color";
const SIDEBAR_WIDTH = 400;
const DEFAULT_EDGE_STYLE = {
type: "bezier",
animated: true,
style: { strokeDasharray: "6 3", stroke: "#58a6ff", strokeWidth: 2 },
label: "",
labelStyle: { fill: "#fff", fontWeight: "bold" },
labelBgStyle: { fill: "#2c2c2c", fillOpacity: 0.85, rx: 16, ry: 16 },
labelBgPadding: [8, 4],
};
let globalEdgeClipboard = null;
function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
export default function Context_Menu_Sidebar({
open,
onClose,
edge,
updateEdge,
}) {
const [activeTab, setActiveTab] = useState(0);
const [editState, setEditState] = useState(() => (edge ? clone(edge) : {}));
const [colorPicker, setColorPicker] = useState({ field: null, anchor: null });
useEffect(() => {
if (edge && edge.id !== editState.id) setEditState(clone(edge));
// eslint-disable-next-line
}, [edge]);
const handleChange = (field, value) => {
setEditState((prev) => {
const updated = { ...prev };
if (field === "label") updated.label = value;
else if (field === "labelStyle.fill") updated.labelStyle = { ...updated.labelStyle, fill: value };
else if (field === "labelBgStyle.fill") updated.labelBgStyle = { ...updated.labelBgStyle, fill: value };
else if (field === "labelBgStyle.rx") updated.labelBgStyle = { ...updated.labelBgStyle, rx: value, ry: value };
else if (field === "labelBgPadding") updated.labelBgPadding = value;
else if (field === "labelBgStyle.fillOpacity") updated.labelBgStyle = { ...updated.labelBgStyle, fillOpacity: value };
else if (field === "type") updated.type = value;
else if (field === "animated") updated.animated = value;
else if (field === "style.stroke") updated.style = { ...updated.style, stroke: value };
else if (field === "style.strokeDasharray") updated.style = { ...updated.style, strokeDasharray: value };
else if (field === "style.strokeWidth") updated.style = { ...updated.style, strokeWidth: value };
else if (field === "labelStyle.fontWeight") updated.labelStyle = { ...updated.labelStyle, fontWeight: value };
else updated[field] = value;
if (field === "style.strokeDasharray") {
if (value === "") {
updated.animated = false;
updated.style = { ...updated.style, strokeDasharray: "" };
} else {
updated.animated = true;
updated.style = { ...updated.style, strokeDasharray: value };
}
}
updateEdge({ ...updated, id: prev.id });
return updated;
});
};
// Color Picker with right alignment
const openColorPicker = (field, event) => {
setColorPicker({ field, anchor: event.currentTarget });
};
const closeColorPicker = () => {
setColorPicker({ field: null, anchor: null });
};
const handleColorChange = (color) => {
handleChange(colorPicker.field, color.hex);
closeColorPicker();
};
// Reset, Copy, Paste logic
const handleReset = () => {
setEditState(clone({ ...DEFAULT_EDGE_STYLE, id: edge.id }));
updateEdge({ ...DEFAULT_EDGE_STYLE, id: edge.id });
};
const handleCopy = () => { globalEdgeClipboard = clone(editState); };
const handlePaste = () => {
if (globalEdgeClipboard) {
setEditState(clone({ ...globalEdgeClipboard, id: edge.id }));
updateEdge({ ...globalEdgeClipboard, id: edge.id });
}
};
const renderColorButton = (label, field, value) => (
<span style={{ display: "inline-block", verticalAlign: "middle", position: "relative" }}>
<Button
variant="outlined"
size="small"
onClick={(e) => openColorPicker(field, e)}
sx={{
ml: 1,
borderColor: "#444",
color: "#ccc",
minWidth: 0,
width: 32,
height: 24,
p: 0,
bgcolor: "#232323",
}}
>
<span style={{
display: "inline-block",
width: 20,
height: 16,
background: value,
borderRadius: 3,
border: "1px solid #888",
}} />
</Button>
{colorPicker.field === field && (
<Box sx={{
position: "absolute",
top: "32px",
right: 0,
zIndex: 1302,
boxShadow: "0 2px 16px rgba(0,0,0,0.24)"
}}>
<SketchPicker
color={value}
onChange={handleColorChange}
disableAlpha
presetColors={[
"#fff", "#000", "#58a6ff", "#ff4f4f", "#2c2c2c", "#00d18c",
"#e3e3e3", "#0475c2", "#ff8c00", "#6b21a8", "#0e7490"
]}
/>
</Box>
)}
</span>
);
// Label tab
const renderLabelTab = () => (
<Box sx={{ px: 2, pt: 1, pb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Label</Typography>
</Box>
<TextField
fullWidth
size="small"
variant="outlined"
value={editState.label || ""}
onChange={e => handleChange("label", e.target.value)}
sx={{
mb: 2,
input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" },
"& fieldset": { borderColor: "#444" },
}}
/>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Text Color</Typography>
{renderColorButton("Label Text Color", "labelStyle.fill", editState.labelStyle?.fill || "#fff")}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Background</Typography>
{renderColorButton("Label Background Color", "labelBgStyle.fill", editState.labelBgStyle?.fill || "#2c2c2c")}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Padding</Typography>
<TextField
size="small"
type="text"
value={editState.labelBgPadding ? editState.labelBgPadding.join(",") : "8,4"}
onChange={e => {
const val = e.target.value.split(",").map(x => parseInt(x.trim())).filter(x => !isNaN(x));
if (val.length === 2) handleChange("labelBgPadding", val);
}}
sx={{ width: 80, input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" } }}
/>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Background Style</Typography>
<TextField
select
size="small"
value={(editState.labelBgStyle?.rx ?? 11) >= 11 ? "rounded" : "square"}
onChange={e => {
handleChange("labelBgStyle.rx", e.target.value === "rounded" ? 11 : 0);
}}
sx={{
width: 150,
bgcolor: "#1e1e1e",
"& .MuiSelect-select": { color: "#fff" }
}}
>
<MenuItem value="rounded">Rounded</MenuItem>
<MenuItem value="square">Square</MenuItem>
</TextField>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Background Opacity</Typography>
<Slider
value={editState.labelBgStyle?.fillOpacity ?? 0.85}
min={0}
max={1}
step={0.05}
onChange={(_, v) => handleChange("labelBgStyle.fillOpacity", v)}
sx={{ width: 100, ml: 2 }}
/>
<TextField
size="small"
type="number"
value={editState.labelBgStyle?.fillOpacity ?? 0.85}
onChange={e => handleChange("labelBgStyle.fillOpacity", parseFloat(e.target.value) || 0)}
sx={{ width: 60, ml: 2, input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" } }}
/>
</Box>
</Box>
);
const renderStyleTab = () => (
<Box sx={{ px: 2, pt: 1, pb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Edge Style</Typography>
<TextField
select
size="small"
value={editState.type || "bezier"}
onChange={e => handleChange("type", e.target.value)}
sx={{
width: 200,
bgcolor: "#1e1e1e",
"& .MuiSelect-select": { color: "#fff" }
}}
>
<MenuItem value="step">Step</MenuItem>
<MenuItem value="bezier">Curved (Bezier)</MenuItem>
<MenuItem value="straight">Straight</MenuItem>
<MenuItem value="smoothstep">Smoothstep</MenuItem>
</TextField>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Edge Animation</Typography>
<TextField
select
size="small"
value={
editState.style?.strokeDasharray === "6 3" ? "dashes"
: editState.style?.strokeDasharray === "2 4" ? "dots"
: "solid"
}
onChange={e => {
const val = e.target.value;
handleChange("style.strokeDasharray",
val === "dashes" ? "6 3" :
val === "dots" ? "2 4" : ""
);
}}
sx={{
width: 200,
bgcolor: "#1e1e1e",
"& .MuiSelect-select": { color: "#fff" }
}}
>
<MenuItem value="dashes">Dashes</MenuItem>
<MenuItem value="dots">Dots</MenuItem>
<MenuItem value="solid">Solid</MenuItem>
</TextField>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Color</Typography>
{renderColorButton("Edge Color", "style.stroke", editState.style?.stroke || "#58a6ff")}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Edge Width</Typography>
<Slider
value={editState.style?.strokeWidth ?? 2}
min={1}
max={10}
step={1}
onChange={(_, v) => handleChange("style.strokeWidth", v)}
sx={{ width: 100, ml: 2 }}
/>
<TextField
size="small"
type="number"
value={editState.style?.strokeWidth ?? 2}
onChange={e => handleChange("style.strokeWidth", parseInt(e.target.value) || 1)}
sx={{ width: 60, ml: 2, input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" } }}
/>
</Box>
</Box>
);
// Always render the sidebar for animation!
if (!edge) return null;
return (
<>
{/* Overlay */}
<Box
onClick={onClose}
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.3)",
opacity: open ? 1 : 0,
pointerEvents: open ? "auto" : "none",
transition: "opacity 0.6s ease",
zIndex: 10
}}
/>
{/* Sidebar */}
<Box
sx={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: SIDEBAR_WIDTH,
bgcolor: "#2C2C2C",
color: "#ccc",
borderLeft: "1px solid #333",
padding: 0,
zIndex: 11,
display: "flex",
flexDirection: "column",
height: "100%",
transform: open ? "translateX(0)" : `translateX(${SIDEBAR_WIDTH}px)`,
transition: "transform 0.3s cubic-bezier(.77,0,.18,1)"
}}
onClick={e => e.stopPropagation()}
>
<Box sx={{ backgroundColor: "#232323", borderBottom: "1px solid #333" }}>
<Box sx={{ padding: "12px 16px", display: "flex", alignItems: "center" }}>
<Typography variant="h7" sx={{ color: "#0475c2", fontWeight: "bold", flex: 1 }}>
Edit Edge Properties
</Typography>
</Box>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
variant="fullWidth"
textColor="inherit"
TabIndicatorProps={{ style: { backgroundColor: "#ccc" } }}
sx={{
borderTop: "1px solid #333",
borderBottom: "1px solid #333",
minHeight: "36px",
height: "36px"
}}
>
<Tab label="Label" sx={{
color: "#ccc",
"&.Mui-selected": { color: "#ccc" },
minHeight: "36px",
height: "36px",
textTransform: "none"
}} />
<Tab label="Style" sx={{
color: "#ccc",
"&.Mui-selected": { color: "#ccc" },
minHeight: "36px",
height: "36px",
textTransform: "none"
}} />
</Tabs>
</Box>
{/* Main fields scrollable */}
<Box sx={{ flex: 1, overflowY: "auto" }}>
{activeTab === 0 && renderLabelTab()}
{activeTab === 1 && renderStyleTab()}
</Box>
{/* Sticky footer bar */}
<Box sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 2, py: 1,
borderTop: "1px solid #333",
backgroundColor: "#232323",
flexShrink: 0
}}>
<Box>
<Tooltip title="Copy Style"><IconButton onClick={handleCopy}><ContentCopyIcon /></IconButton></Tooltip>
<Tooltip title="Paste Style"><IconButton onClick={handlePaste}><ContentPasteIcon /></IconButton></Tooltip>
</Box>
<Box>
<Tooltip title="Reset to Default"><Button variant="outlined" size="small" startIcon={<RestoreIcon />} onClick={handleReset} sx={{
color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none"
}}>Reset to Default</Button></Tooltip>
</Box>
</Box>
</Box>
</>
);
}

View File

@ -1,7 +1,7 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Flow_Editor.jsx
// Import Node Configuration Sidebar
// Import Node Configuration Sidebar and new Context Menu Sidebar
import NodeConfigurationSidebar from "./Node_Configuration_Sidebar";
import ContextMenuSidebar from "./Context_Menu_Sidebar";
import React, { useState, useEffect, useCallback, useRef } from "react";
import ReactFlow, {
@ -12,24 +12,13 @@ import ReactFlow, {
useReactFlow
} from "reactflow";
import {
Menu,
MenuItem,
MenuList,
Slider,
Box
} from "@mui/material";
import { Menu, MenuItem, Box } from "@mui/material";
import {
Polyline as PolylineIcon,
DeleteForever as DeleteForeverIcon,
Edit as EditIcon
} from "@mui/icons-material";
import {
SketchPicker
} from "react-color";
import "reactflow/dist/style.css";
export default function FlowEditor({
@ -45,60 +34,88 @@ export default function FlowEditor({
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedNodeId, setSelectedNodeId] = useState(null);
useEffect(() => {
window.BorealisOpenDrawer = (id) => {
setSelectedNodeId(id);
setDrawerOpen(true);
};
return () => {
delete window.BorealisOpenDrawer;
};
}, []);
// Edge Properties Sidebar State
const [edgeSidebarOpen, setEdgeSidebarOpen] = useState(false);
const [edgeSidebarEdgeId, setEdgeSidebarEdgeId] = useState(null);
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
// Context Menus
const [nodeContextMenu, setNodeContextMenu] = useState(null); // { mouseX, mouseY, nodeId }
const [edgeContextMenu, setEdgeContextMenu] = useState(null); // { mouseX, mouseY, edgeId }
// Drag/snap helpers (untouched)
const wrapperRef = useRef(null);
const { project } = useReactFlow();
const [contextMenu, setContextMenu] = useState(null);
const [edgeContextMenu, setEdgeContextMenu] = useState(null);
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
const [showColorPicker, setShowColorPicker] = useState(false);
const [colorPickerMode, setColorPickerMode] = useState(null);
const [labelPadding, setLabelPadding] = useState([8, 4]);
const [labelBorderRadius, setLabelBorderRadius] = useState(4);
const [labelOpacity, setLabelOpacity] = useState(0.8);
const [tempColor, setTempColor] = useState({ hex: "#58a6ff" });
const [pickerPos, setPickerPos] = useState({ x: 0, y: 0 });
// helper-line state
// guides: array of { xFlow, xPx } or { yFlow, yPx } for stationary nodes
const [guides, setGuides] = useState([]);
// activeGuides: array of { xPx } or { yPx } to draw
const [activeGuides, setActiveGuides] = useState([]);
// store moving node flow-size on drag start
const movingFlowSize = useRef({ width: 0, height: 0 });
const edgeStyles = {
step: "step",
curved: "bezier",
straight: "straight",
smoothstep: "smoothstep"
// ----- Node/Edge Definitions -----
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
const selectedEdge = edges.find((e) => e.id === edgeSidebarEdgeId);
// --------- Context Menu Handlers ----------
const handleRightClick = (e, node) => {
e.preventDefault();
setNodeContextMenu({ mouseX: e.clientX + 2, mouseY: e.clientY - 6, nodeId: node.id });
};
const animationStyles = {
dashes: { animated: true, style: { strokeDasharray: "6 3" } },
dots: { animated: true, style: { strokeDasharray: "2 4" } },
none: { animated: false, style: {} }
const handleEdgeRightClick = (e, edge) => {
e.preventDefault();
setEdgeContextMenu({ mouseX: e.clientX + 2, mouseY: e.clientY - 6, edgeId: edge.id });
};
// Compute edge-only guides and capture moving node flow-size
// --------- Node Context Menu Actions ---------
const handleDisconnectAllEdges = (nodeId) => {
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
setNodeContextMenu(null);
};
const handleRemoveNode = (nodeId) => {
setNodes((nds) => nds.filter((n) => n.id !== nodeId));
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
setNodeContextMenu(null);
};
const handleEditNodeProps = (nodeId) => {
setSelectedNodeId(nodeId);
setDrawerOpen(true);
setNodeContextMenu(null);
};
// --------- Edge Context Menu Actions ---------
const handleUnlinkEdge = (edgeId) => {
setEdges((eds) => eds.filter((e) => e.id !== edgeId));
setEdgeContextMenu(null);
};
const handleEditEdgeProps = (edgeId) => {
setEdgeSidebarEdgeId(edgeId);
setEdgeSidebarOpen(true);
setEdgeContextMenu(null);
};
// ----- Sidebar Closing -----
const handleCloseNodeSidebar = () => {
setDrawerOpen(false);
setSelectedNodeId(null);
};
const handleCloseEdgeSidebar = () => {
setEdgeSidebarOpen(false);
setEdgeSidebarEdgeId(null);
};
// ----- Update Edge Callback for Sidebar -----
const updateEdge = (updatedEdgeObj) => {
setEdges((eds) =>
eds.map((e) => (e.id === updatedEdgeObj.id ? { ...e, ...updatedEdgeObj } : e))
);
};
// ----- Drag/Drop, Guides, Node Snap Logic (unchanged) -----
const computeGuides = useCallback((dragNode) => {
if (!wrapperRef.current) return;
const parentRect = wrapperRef.current.getBoundingClientRect();
// measure moving node in pixel space
const dragEl = wrapperRef.current.querySelector(
`.react-flow__node[data-id="${dragNode.id}"]`
);
@ -108,18 +125,11 @@ export default function FlowEditor({
const relTop = dr.top - parentRect.top;
const relRight = relLeft + dr.width;
const relBottom = relTop + dr.height;
// project pixel corners to flow coords
const pTL = project({ x: relLeft, y: relTop });
const pTR = project({ x: relRight, y: relTop });
const pBL = project({ x: relLeft, y: relBottom });
movingFlowSize.current = {
width: pTR.x - pTL.x,
height: pBL.y - pTL.y
};
movingFlowSize.current = { width: pTR.x - pTL.x, height: pBL.y - pTL.y };
}
const lines = [];
nodes.forEach((n) => {
if (n.id === dragNode.id) return;
@ -132,57 +142,32 @@ export default function FlowEditor({
const relTop = r.top - parentRect.top;
const relRight = relLeft + r.width;
const relBottom = relTop + r.height;
// project pixel to flow coords
const pTL = project({ x: relLeft, y: relTop });
const pTR = project({ x: relRight, y: relTop });
const pBL = project({ x: relLeft, y: relBottom });
// vertical guides: left edge, right edge
lines.push({ xFlow: pTL.x, xPx: relLeft });
lines.push({ xFlow: pTR.x, xPx: relRight });
// horizontal guides: top edge, bottom edge
lines.push({ yFlow: pTL.y, yPx: relTop });
lines.push({ yFlow: pBL.y, yPx: relBottom });
});
setGuides(lines);
}, [nodes, project]);
// Snap & show only matching guides within threshold during drag
const onNodeDrag = useCallback((_, node) => {
const threshold = 5;
let snapX = null, snapY = null;
const show = [];
const { width: fw, height: fh } = movingFlowSize.current;
guides.forEach((ln) => {
if (ln.xFlow != null) {
// moving left edge to stationary edges
if (Math.abs(node.position.x - ln.xFlow) < threshold) {
snapX = ln.xFlow;
show.push({ xPx: ln.xPx });
}
// moving right edge to stationary edges
else if (Math.abs(node.position.x + fw - ln.xFlow) < threshold) {
snapX = ln.xFlow - fw;
show.push({ xPx: ln.xPx });
}
if (Math.abs(node.position.x - ln.xFlow) < threshold) { snapX = ln.xFlow; show.push({ xPx: ln.xPx }); }
else if (Math.abs(node.position.x + fw - ln.xFlow) < threshold) { snapX = ln.xFlow - fw; show.push({ xPx: ln.xPx }); }
}
if (ln.yFlow != null) {
// moving top edge
if (Math.abs(node.position.y - ln.yFlow) < threshold) {
snapY = ln.yFlow;
show.push({ yPx: ln.yPx });
}
// moving bottom edge
else if (Math.abs(node.position.y + fh - ln.yFlow) < threshold) {
snapY = ln.yFlow - fh;
show.push({ yPx: ln.yPx });
}
if (Math.abs(node.position.y - ln.yFlow) < threshold) { snapY = ln.yFlow; show.push({ yPx: ln.yPx }); }
else if (Math.abs(node.position.y + fh - ln.yFlow) < threshold) { snapY = ln.yFlow - fh; show.push({ yPx: ln.yPx }); }
}
});
if (snapX !== null || snapY !== null) {
setNodes((nds) =>
applyNodeChanges(
@ -251,104 +236,6 @@ export default function FlowEditor({
setEdges((eds) => applyEdgeChanges(changes, eds));
}, [setEdges]);
const handleRightClick = (e, node) => {
e.preventDefault();
setContextMenu({ mouseX: e.clientX + 2, mouseY: e.clientY - 6, nodeId: node.id });
};
const handleEdgeRightClick = (e, edge) => {
e.preventDefault();
setEdgeContextMenu({ edgeId: edge.id, mouseX: e.clientX + 2, mouseY: e.clientY - 6 });
setSelectedEdgeId(edge.id);
};
const changeEdgeType = (newType) => {
setEdges((eds) => eds.map((e) =>
e.id === selectedEdgeId ? { ...e, type: edgeStyles[newType] } : e
));
setEdgeContextMenu(null);
};
const changeEdgeAnimation = (newAnim) => {
setEdges((eds) => eds.map((e) => {
if (e.id !== selectedEdgeId) return e;
const strokeColor = e.style?.stroke || "#58a6ff";
const anim = animationStyles[newAnim] || {};
return {
...e,
animated: anim.animated,
style: { ...anim.style, stroke: strokeColor },
markerEnd: e.markerEnd ? { ...e.markerEnd, color: strokeColor } : undefined
};
}));
setEdgeContextMenu(null);
};
const handleColorChange = (color) => {
setEdges((eds) => eds.map((e) => {
if (e.id !== selectedEdgeId) return e;
const updated = { ...e };
if (colorPickerMode === "stroke") {
updated.style = { ...e.style, stroke: color.hex };
if (e.markerEnd) updated.markerEnd = { ...e.markerEnd, color: color.hex };
} else if (colorPickerMode === "labelText") {
updated.labelStyle = { ...e.labelStyle, fill: color.hex };
} else if (colorPickerMode === "labelBg") {
updated.labelBgStyle = { ...e.labelBgStyle, fill: color.hex, fillOpacity: labelOpacity };
}
return updated;
}));
};
const handleAddLabel = () => {
setEdges((eds) => eds.map((e) =>
e.id === selectedEdgeId ? { ...e, label: "New Label" } : e
));
setEdgeContextMenu(null);
};
const handleEditLabel = () => {
const newText = prompt("Enter label text:");
if (newText !== null) {
setEdges((eds) => eds.map((e) =>
e.id === selectedEdgeId ? { ...e, label: newText } : e
));
}
setEdgeContextMenu(null);
};
const handleRemoveLabel = () => {
setEdges((eds) => eds.map((e) =>
e.id === selectedEdgeId ? { ...e, label: undefined } : e
));
setEdgeContextMenu(null);
};
const handlePickColor = (mode) => {
setColorPickerMode(mode);
setTempColor({ hex: "#58a6ff" });
setPickerPos({ x: edgeContextMenu?.mouseX || 0, y: edgeContextMenu?.mouseY || 0 });
setShowColorPicker(true);
};
const applyLabelStyleExtras = () => {
setEdges((eds) => eds.map((e) =>
e.id === selectedEdgeId
? {
...e,
labelBgPadding: labelPadding,
labelBgStyle: {
...e.labelBgStyle,
fillOpacity: labelOpacity,
rx: labelBorderRadius,
ry: labelBorderRadius
}
}
: e
));
setEdgeContextMenu(null);
};
useEffect(() => {
const nodeCountEl = document.getElementById("nodeCount");
if (nodeCountEl) nodeCountEl.innerText = nodes.length;
@ -358,13 +245,14 @@ export default function FlowEditor({
? Object.values(categorizedNodes).flat().find((def) => def.type === selectedNode.type)
: null;
// --------- MAIN RENDER ----------
return (
<div
className="flow-editor-container"
ref={wrapperRef}
style={{ position: "relative" }}
>
{/* Node Config Sidebar */}
<NodeConfigurationSidebar
drawerOpen={drawerOpen}
setDrawerOpen={setDrawerOpen}
@ -381,6 +269,17 @@ export default function FlowEditor({
}
/>
{/* Edge Properties Sidebar */}
<ContextMenuSidebar
open={edgeSidebarOpen}
onClose={handleCloseEdgeSidebar}
edge={selectedEdge ? { ...selectedEdge } : null}
updateEdge={edge => {
// Provide id if missing
if (!edge.id && edgeSidebarEdgeId) edge.id = edgeSidebarEdgeId;
updateEdge(edge);
}}
/>
<ReactFlow
nodes={nodes}
@ -403,7 +302,7 @@ export default function FlowEditor({
<Background id={flowId} variant="lines" gap={65} size={1} color="rgba(255,255,255,0.2)" />
</ReactFlow>
{/* helper lines */}
{/* Helper lines for snapping */}
{activeGuides.map((ln, i) =>
ln.xPx != null ? (
<div
@ -420,38 +319,29 @@ export default function FlowEditor({
)
)}
{/* Node Context Menu */}
<Menu
open={Boolean(contextMenu)}
onClose={() => setContextMenu(null)}
open={Boolean(nodeContextMenu)}
onClose={() => setNodeContextMenu(null)}
anchorReference="anchorPosition"
anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
anchorPosition={nodeContextMenu ? { top: nodeContextMenu.mouseY, left: nodeContextMenu.mouseX } : undefined}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
>
<MenuItem onClick={() => {
if (contextMenu?.nodeId) {
setEdges((eds) => eds.filter((e) =>
e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId
));
}
setContextMenu(null);
}}>
<MenuItem onClick={() => handleEditNodeProps(nodeContextMenu.nodeId)}>
<EditIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
Edit Properties
</MenuItem>
<MenuItem onClick={() => handleDisconnectAllEdges(nodeContextMenu.nodeId)}>
<PolylineIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
Disconnect All Edges
</MenuItem>
<MenuItem onClick={() => {
if (contextMenu?.nodeId) {
setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId));
setEdges((eds) => eds.filter((e) =>
e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId
));
}
setContextMenu(null);
}}>
<MenuItem onClick={() => handleRemoveNode(nodeContextMenu.nodeId)}>
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
Remove Node
</MenuItem>
</Menu>
{/* Edge Context Menu */}
<Menu
open={Boolean(edgeContextMenu)}
onClose={() => setEdgeContextMenu(null)}
@ -459,129 +349,15 @@ export default function FlowEditor({
anchorPosition={edgeContextMenu ? { top: edgeContextMenu.mouseY, left: edgeContextMenu.mouseX } : undefined}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
>
<MenuItem onClick={() => setEdges((eds) => eds.filter((e) => e.id !== selectedEdgeId))}>
<MenuItem onClick={() => handleEditEdgeProps(edgeContextMenu.edgeId)}>
<EditIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
Edit Properties
</MenuItem>
<MenuItem onClick={() => handleUnlinkEdge(edgeContextMenu.edgeId)}>
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
Unlink Edge
</MenuItem>
<MenuItem>
Edge Styles
<MenuList>
<MenuItem onClick={() => changeEdgeType("step")}>Step</MenuItem>
<MenuItem onClick={() => changeEdgeType("curved")}>Curved</MenuItem>
<MenuItem onClick={() => changeEdgeType("straight")}>Straight</MenuItem>
<MenuItem onClick={() => changeEdgeType("smoothstep")}>Smoothstep</MenuItem>
</MenuList>
</MenuItem>
<MenuItem>
Animations
<MenuList>
<MenuItem onClick={() => changeEdgeAnimation("dashes")}>Dashes</MenuItem>
<MenuItem onClick={() => changeEdgeAnimation("dots")}>Dots</MenuItem>
<MenuItem onClick={() => changeEdgeAnimation("none")}>Solid Line</MenuItem>
</MenuList>
</MenuItem>
<MenuItem>
Label
<MenuList>
<MenuItem onClick={handleAddLabel}>Add</MenuItem>
<MenuItem onClick={handleRemoveLabel}>Remove</MenuItem>
<MenuItem onClick={handleEditLabel}>
<EditIcon sx={{ fontSize: 16, mr: 1 }} />
Edit
</MenuItem>
<MenuItem onClick={() => handlePickColor("labelText")}>Text Color</MenuItem>
<MenuItem onClick={() => handlePickColor("labelBg")}>Background Color</MenuItem>
<MenuItem>
Padding:
<input
type="text"
defaultValue={`${labelPadding[0]},${labelPadding[1]}`}
style={{ width: 80, marginLeft: 8 }}
onBlur={(e) => {
const parts = e.target.value.split(",").map((v) => parseInt(v.trim()));
if (parts.length === 2 && parts.every(Number.isFinite)) setLabelPadding(parts);
}}
/>
</MenuItem>
<MenuItem>
Radius:
<input
type="number"
min="0"
max="20"
defaultValue={labelBorderRadius}
style={{ width: 60, marginLeft: 8 }}
onBlur={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val)) setLabelBorderRadius(val);
}}
/>
</MenuItem>
<MenuItem>
Opacity:
<Box display="flex" alignItems="center" ml={1}>
<Slider
value={labelOpacity}
onChange={(_, v) => setLabelOpacity(v)}
step={0.05}
min={0}
max={1}
style={{ width: 100 }}
/>
<input
type="number"
step="0.05"
min="0"
max="1"
value={labelOpacity}
style={{ width: 60, marginLeft: 8 }}
onChange={(e) => {
const v = parseFloat(e.target.value);
if (!isNaN(v)) setLabelOpacity(v);
}}
/>
</Box>
</MenuItem>
<MenuItem onClick={applyLabelStyleExtras}>Apply Label Style Changes</MenuItem>
</MenuList>
</MenuItem>
<MenuItem onClick={() => handlePickColor("stroke")}>Color</MenuItem>
</Menu>
{showColorPicker && (
<div
style={{
position: "absolute",
top: pickerPos.y,
left: pickerPos.x,
zIndex: 9999,
background: "#1e1e1e",
padding: "10px",
borderRadius: "8px"
}}
>
<SketchPicker color={tempColor.hex} onChange={(c) => setTempColor(c)} />
<div style={{ marginTop: "10px", textAlign: "center" }}>
<button
onClick={() => {
handleColorChange(tempColor);
setShowColorPicker(false);
}}
style={{
backgroundColor: "#58a6ff",
color: "#121212",
border: "none",
padding: "6px 12px",
borderRadius: "4px",
cursor: "pointer",
fontWeight: "bold"
}}
>
Set Color
</button>
</div>
</div>
)}
</div>
);
}