mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 15:21:57 -06:00
Mass-Restructure of JSX Folder Structure
This commit is contained in:
415
Data/Server/WebUI/src/Flow_Editor/Context_Menu_Sidebar.jsx
Normal file
415
Data/Server/WebUI/src/Flow_Editor/Context_Menu_Sidebar.jsx
Normal 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: 1 },
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
374
Data/Server/WebUI/src/Flow_Editor/Flow_Editor.jsx
Normal file
374
Data/Server/WebUI/src/Flow_Editor/Flow_Editor.jsx
Normal file
@@ -0,0 +1,374 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Flow_Editor.jsx
|
||||
// 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, {
|
||||
Background,
|
||||
addEdge,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
useReactFlow
|
||||
} from "reactflow";
|
||||
|
||||
import { Menu, MenuItem, Box } from "@mui/material";
|
||||
import {
|
||||
Polyline as PolylineIcon,
|
||||
DeleteForever as DeleteForeverIcon,
|
||||
Edit as EditIcon
|
||||
} from "@mui/icons-material";
|
||||
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
export default function FlowEditor({
|
||||
flowId,
|
||||
nodes,
|
||||
edges,
|
||||
setNodes,
|
||||
setEdges,
|
||||
nodeTypes,
|
||||
categorizedNodes
|
||||
}) {
|
||||
// Node Configuration Sidebar State
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(null);
|
||||
|
||||
// Edge Properties Sidebar State
|
||||
const [edgeSidebarOpen, setEdgeSidebarOpen] = useState(false);
|
||||
const [edgeSidebarEdgeId, setEdgeSidebarEdgeId] = useState(null);
|
||||
|
||||
// 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 [guides, setGuides] = useState([]);
|
||||
const [activeGuides, setActiveGuides] = useState([]);
|
||||
const movingFlowSize = useRef({ width: 0, height: 0 });
|
||||
|
||||
// ----- 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 handleEdgeRightClick = (e, edge) => {
|
||||
e.preventDefault();
|
||||
setEdgeContextMenu({ mouseX: e.clientX + 2, mouseY: e.clientY - 6, edgeId: edge.id });
|
||||
};
|
||||
|
||||
// --------- 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();
|
||||
const dragEl = wrapperRef.current.querySelector(
|
||||
`.react-flow__node[data-id="${dragNode.id}"]`
|
||||
);
|
||||
if (dragEl) {
|
||||
const dr = dragEl.getBoundingClientRect();
|
||||
const relLeft = dr.left - parentRect.left;
|
||||
const relTop = dr.top - parentRect.top;
|
||||
const relRight = relLeft + dr.width;
|
||||
const relBottom = relTop + dr.height;
|
||||
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 };
|
||||
}
|
||||
const lines = [];
|
||||
nodes.forEach((n) => {
|
||||
if (n.id === dragNode.id) return;
|
||||
const el = wrapperRef.current.querySelector(
|
||||
`.react-flow__node[data-id="${n.id}"]`
|
||||
);
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const relLeft = r.left - parentRect.left;
|
||||
const relTop = r.top - parentRect.top;
|
||||
const relRight = relLeft + r.width;
|
||||
const relBottom = relTop + r.height;
|
||||
const pTL = project({ x: relLeft, y: relTop });
|
||||
const pTR = project({ x: relRight, y: relTop });
|
||||
const pBL = project({ x: relLeft, y: relBottom });
|
||||
lines.push({ xFlow: pTL.x, xPx: relLeft });
|
||||
lines.push({ xFlow: pTR.x, xPx: relRight });
|
||||
lines.push({ yFlow: pTL.y, yPx: relTop });
|
||||
lines.push({ yFlow: pBL.y, yPx: relBottom });
|
||||
});
|
||||
setGuides(lines);
|
||||
}, [nodes, project]);
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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(
|
||||
[{
|
||||
id: node.id,
|
||||
type: "position",
|
||||
position: {
|
||||
x: snapX !== null ? snapX : node.position.x,
|
||||
y: snapY !== null ? snapY : node.position.y
|
||||
}
|
||||
}],
|
||||
nds
|
||||
)
|
||||
);
|
||||
setActiveGuides(show);
|
||||
} else {
|
||||
setActiveGuides([]);
|
||||
}
|
||||
}, [guides, setNodes]);
|
||||
|
||||
const onDrop = useCallback((event) => {
|
||||
event.preventDefault();
|
||||
const type = event.dataTransfer.getData("application/reactflow");
|
||||
if (!type) return;
|
||||
const bounds = wrapperRef.current.getBoundingClientRect();
|
||||
const position = project({
|
||||
x: event.clientX - bounds.left,
|
||||
y: event.clientY - bounds.top
|
||||
});
|
||||
const id = "node-" + Date.now();
|
||||
const nodeMeta = Object.values(categorizedNodes).flat().find((n) => n.type === type);
|
||||
// Seed config defaults:
|
||||
const configDefaults = {};
|
||||
(nodeMeta?.config || []).forEach(cfg => {
|
||||
if (cfg.defaultValue !== undefined) {
|
||||
configDefaults[cfg.key] = cfg.defaultValue;
|
||||
}
|
||||
});
|
||||
const newNode = {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
data: {
|
||||
label: nodeMeta?.label || type,
|
||||
content: nodeMeta?.content,
|
||||
...configDefaults
|
||||
},
|
||||
dragHandle: ".borealis-node-header"
|
||||
};
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
|
||||
}, [project, setNodes, categorizedNodes]);
|
||||
|
||||
const onDragOver = useCallback((event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}, []);
|
||||
|
||||
const onConnect = useCallback((params) => {
|
||||
setEdges((eds) =>
|
||||
addEdge({
|
||||
...params,
|
||||
type: "bezier",
|
||||
animated: true,
|
||||
style: { strokeDasharray: "6 3", stroke: "#58a6ff" }
|
||||
}, eds)
|
||||
);
|
||||
}, [setEdges]);
|
||||
|
||||
const onNodesChange = useCallback((changes) => {
|
||||
setNodes((nds) => applyNodeChanges(changes, nds));
|
||||
}, [setNodes]);
|
||||
|
||||
const onEdgesChange = useCallback((changes) => {
|
||||
setEdges((eds) => applyEdgeChanges(changes, eds));
|
||||
}, [setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
const nodeCountEl = document.getElementById("nodeCount");
|
||||
if (nodeCountEl) nodeCountEl.innerText = nodes.length;
|
||||
}, [nodes]);
|
||||
|
||||
const nodeDef = selectedNode
|
||||
? 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}
|
||||
title={selectedNode ? selectedNode.data?.label || selectedNode.id : ""}
|
||||
nodeData={
|
||||
selectedNode && nodeDef
|
||||
? {
|
||||
config: nodeDef.config,
|
||||
usage_documentation: nodeDef.usage_documentation,
|
||||
...selectedNode.data,
|
||||
nodeId: selectedNode.id
|
||||
}
|
||||
: null
|
||||
}
|
||||
setNodes={setNodes}
|
||||
selectedNode={selectedNode}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onNodeContextMenu={handleRightClick}
|
||||
onEdgeContextMenu={handleEdgeRightClick}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
|
||||
edgeOptions={{ type: "bezier", animated: true, style: { strokeDasharray: "6 3", stroke: "#58a6ff" } }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onNodeDragStart={(_, node) => computeGuides(node)}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={() => { setGuides([]); setActiveGuides([]); }}
|
||||
>
|
||||
<Background id={flowId} variant="lines" gap={65} size={1} color="rgba(255,255,255,0.2)" />
|
||||
</ReactFlow>
|
||||
|
||||
{/* Helper lines for snapping */}
|
||||
{activeGuides.map((ln, i) =>
|
||||
ln.xPx != null ? (
|
||||
<div
|
||||
key={i}
|
||||
className="helper-line helper-line-vertical"
|
||||
style={{ left: ln.xPx + "px", top: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={i}
|
||||
className="helper-line helper-line-horizontal"
|
||||
style={{ top: ln.yPx + "px", left: 0 }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Node Context Menu */}
|
||||
<Menu
|
||||
open={Boolean(nodeContextMenu)}
|
||||
onClose={() => setNodeContextMenu(null)}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={nodeContextMenu ? { top: nodeContextMenu.mouseY, left: nodeContextMenu.mouseX } : undefined}
|
||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
||||
>
|
||||
<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={() => 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)}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={edgeContextMenu ? { top: edgeContextMenu.mouseY, left: edgeContextMenu.mouseX } : undefined}
|
||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
||||
>
|
||||
<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>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
Data/Server/WebUI/src/Flow_Editor/Flow_Tabs.jsx
Normal file
100
Data/Server/WebUI/src/Flow_Editor/Flow_Tabs.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Flow_Tabs.jsx
|
||||
|
||||
import React from "react";
|
||||
import { Box, Tabs, Tab, Tooltip } from "@mui/material";
|
||||
import { Add as AddIcon } from "@mui/icons-material";
|
||||
|
||||
/**
|
||||
* Renders the tab bar (including the "add tab" button).
|
||||
*
|
||||
* Props:
|
||||
* - tabs (array of {id, tab_name, nodes, edges})
|
||||
* - activeTabId (string)
|
||||
* - onTabChange(newActiveTabId: string)
|
||||
* - onAddTab()
|
||||
* - onTabRightClick(evt: MouseEvent, tabId: string)
|
||||
*/
|
||||
export default function FlowTabs({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onTabChange,
|
||||
onAddTab,
|
||||
onTabRightClick
|
||||
}) {
|
||||
// Determine the currently active tab index
|
||||
const activeIndex = (() => {
|
||||
const idx = tabs.findIndex((t) => t.id === activeTabId);
|
||||
return idx >= 0 ? idx : 0;
|
||||
})();
|
||||
|
||||
// Handle tab clicks
|
||||
const handleChange = (event, newValue) => {
|
||||
if (newValue === "__addtab__") {
|
||||
// The "plus" tab
|
||||
onAddTab();
|
||||
} else {
|
||||
// normal tab index
|
||||
const newTab = tabs[newValue];
|
||||
if (newTab) {
|
||||
onTabChange(newTab.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#232323",
|
||||
borderBottom: "1px solid #333",
|
||||
height: "36px"
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeIndex}
|
||||
onChange={handleChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
textColor="inherit"
|
||||
TabIndicatorProps={{
|
||||
style: { backgroundColor: "#58a6ff" }
|
||||
}}
|
||||
sx={{
|
||||
minHeight: "36px",
|
||||
height: "36px",
|
||||
flexGrow: 1
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
label={tab.tab_name}
|
||||
value={index}
|
||||
onContextMenu={(evt) => onTabRightClick(evt, tab.id)}
|
||||
sx={{
|
||||
minHeight: "36px",
|
||||
height: "36px",
|
||||
textTransform: "none",
|
||||
backgroundColor: tab.id === activeTabId ? "#2C2C2C" : "transparent",
|
||||
color: "#58a6ff"
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* The "plus" tab has a special value */}
|
||||
<Tooltip title="Create a New Concurrent Tab" arrow>
|
||||
<Tab
|
||||
icon={<AddIcon />}
|
||||
value="__addtab__"
|
||||
sx={{
|
||||
minHeight: "36px",
|
||||
height: "36px",
|
||||
color: "#58a6ff",
|
||||
textTransform: "none"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
449
Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx
Normal file
449
Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx
Normal file
@@ -0,0 +1,449 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Node_Configuration_Sidebar.jsx
|
||||
import { Box, Typography, Tabs, Tab, TextField, MenuItem, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, Button, Tooltip } from "@mui/material";
|
||||
import React, { useState } from "react";
|
||||
import { useReactFlow } from "reactflow";
|
||||
import ReactMarkdown from "react-markdown"; // Used for Node Usage Documentation
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import PaletteIcon from "@mui/icons-material/Palette";
|
||||
import { SketchPicker } from "react-color";
|
||||
|
||||
// ---- NEW: Brightness utility for gradient ----
|
||||
function darkenColor(hex, percent = 0.7) {
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return hex;
|
||||
let r = parseInt(hex.slice(1, 3), 16);
|
||||
let g = parseInt(hex.slice(3, 5), 16);
|
||||
let b = parseInt(hex.slice(5, 7), 16);
|
||||
r = Math.round(r * percent);
|
||||
g = Math.round(g * percent);
|
||||
b = Math.round(b * percent);
|
||||
return `#${r.toString(16).padStart(2,"0")}${g.toString(16).padStart(2,"0")}${b.toString(16).padStart(2,"0")}`;
|
||||
}
|
||||
// --------------------------------------------
|
||||
|
||||
export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, title, nodeData, setNodes, selectedNode }) {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const contextSetNodes = useReactFlow().setNodes;
|
||||
// Use setNodes from props if provided, else fallback to context (for backward compatibility)
|
||||
const effectiveSetNodes = setNodes || contextSetNodes;
|
||||
const handleTabChange = (_, newValue) => setActiveTab(newValue);
|
||||
|
||||
// Rename dialog state
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(title || "");
|
||||
|
||||
// ---- NEW: Accent Color Picker ----
|
||||
const [colorDialogOpen, setColorDialogOpen] = useState(false);
|
||||
const accentColor = selectedNode?.data?.accentColor || "#58a6ff";
|
||||
// ----------------------------------
|
||||
|
||||
const renderConfigFields = () => {
|
||||
const config = nodeData?.config || [];
|
||||
const nodeId = nodeData?.nodeId;
|
||||
|
||||
return config.map((field, index) => {
|
||||
const value = nodeData?.[field.key] || "";
|
||||
|
||||
// ---- DYNAMIC DROPDOWN SUPPORT ----
|
||||
if (field.type === "select") {
|
||||
let options = field.options || [];
|
||||
|
||||
// Handle dynamic options for things like Target Window
|
||||
if (field.dynamicOptions && nodeData?.windowList && Array.isArray(nodeData.windowList)) {
|
||||
options = nodeData.windowList
|
||||
.map(win => ({
|
||||
value: String(win.handle),
|
||||
label: `${win.title} (${win.handle})`
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
|
||||
} else {
|
||||
options = options.map(opt => ({ value: opt, label: opt }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: "#ccc", mb: 0.5 }}>
|
||||
{field.label || field.key}
|
||||
</Typography>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
if (!nodeId) return;
|
||||
effectiveSetNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === nodeId
|
||||
? { ...n, data: { ...n.data, [field.key]: newValue } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
window.BorealisValueBus[nodeId] = newValue;
|
||||
}}
|
||||
SelectProps={{
|
||||
MenuProps: {
|
||||
PaperProps: {
|
||||
sx: {
|
||||
bgcolor: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #58a6ff",
|
||||
"& .MuiMenuItem-root": {
|
||||
color: "#ccc",
|
||||
fontSize: "0.85rem",
|
||||
"&:hover": {
|
||||
backgroundColor: "#2a2a2a"
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: "#2c2c2c !important",
|
||||
color: "#58a6ff"
|
||||
},
|
||||
"&.Mui-selected:hover": {
|
||||
backgroundColor: "#2a2a2a !important"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
fontSize: "0.85rem",
|
||||
"& fieldset": {
|
||||
borderColor: "#444"
|
||||
},
|
||||
"&:hover fieldset": {
|
||||
borderColor: "#58a6ff"
|
||||
},
|
||||
"&.Mui-focused fieldset": {
|
||||
borderColor: "#58a6ff"
|
||||
}
|
||||
},
|
||||
"& .MuiSelect-select": {
|
||||
backgroundColor: "#1e1e1e"
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<MenuItem disabled value="">
|
||||
{field.label === "Target Window"
|
||||
? "No windows detected"
|
||||
: "No options"}
|
||||
</MenuItem>
|
||||
) : (
|
||||
options.map((opt, idx) => (
|
||||
<MenuItem key={idx} value={opt.value}>
|
||||
{opt.label}
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</TextField>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// ---- END DYNAMIC DROPDOWN SUPPORT ----
|
||||
|
||||
return (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: "#ccc", mb: 0.5 }}>
|
||||
{field.label || field.key}
|
||||
</Typography>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
if (!nodeId) return;
|
||||
effectiveSetNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === nodeId
|
||||
? { ...n, data: { ...n.data, [field.key]: newValue } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
window.BorealisValueBus[nodeId] = newValue;
|
||||
}}
|
||||
InputProps={{
|
||||
sx: {
|
||||
backgroundColor: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
"& fieldset": { borderColor: "#444" },
|
||||
"&:hover fieldset": { borderColor: "#666" },
|
||||
"&.Mui-focused fieldset": { borderColor: "#58a6ff" }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// ---- NEW: Accent Color Button ----
|
||||
const renderAccentColorButton = () => (
|
||||
<Tooltip title="Override Node Header/Accent Color">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Override Node Color"
|
||||
onClick={() => setColorDialogOpen(true)}
|
||||
sx={{
|
||||
ml: 1,
|
||||
border: "1px solid #58a6ff",
|
||||
background: accentColor,
|
||||
color: "#222",
|
||||
width: 28, height: 28, p: 0
|
||||
}}
|
||||
>
|
||||
<PaletteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
// ----------------------------------
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.3)",
|
||||
opacity: drawerOpen ? 1 : 0,
|
||||
pointerEvents: drawerOpen ? "auto" : "none",
|
||||
transition: "opacity 0.6s ease",
|
||||
zIndex: 10
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 400,
|
||||
bgcolor: "#2C2C2C",
|
||||
color: "#ccc",
|
||||
borderLeft: "1px solid #333",
|
||||
padding: 0,
|
||||
zIndex: 11,
|
||||
overflowY: "auto",
|
||||
transform: drawerOpen ? "translateX(0)" : "translateX(100%)",
|
||||
transition: "transform 0.3s ease"
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Box sx={{ backgroundColor: "#232323", borderBottom: "1px solid #333" }}>
|
||||
<Box sx={{ padding: "12px 16px" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Typography variant="h7" sx={{ color: "#0475c2", fontWeight: "bold" }}>
|
||||
{"Edit " + (title || "Node")}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Rename Node"
|
||||
onClick={() => {
|
||||
setRenameValue(title || "");
|
||||
setRenameOpen(true);
|
||||
}}
|
||||
sx={{ ml: 1, color: "#58a6ff" }}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{/* ---- NEW: Accent Color Picker button next to pencil ---- */}
|
||||
{renderAccentColorButton()}
|
||||
{/* ------------------------------------------------------ */}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="fullWidth"
|
||||
textColor="inherit"
|
||||
TabIndicatorProps={{ style: { backgroundColor: "#ccc" } }}
|
||||
sx={{
|
||||
borderTop: "1px solid #333",
|
||||
borderBottom: "1px solid #333",
|
||||
minHeight: "36px",
|
||||
height: "36px"
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label="Config"
|
||||
sx={{
|
||||
color: "#ccc",
|
||||
"&.Mui-selected": { color: "#ccc" },
|
||||
minHeight: "36px",
|
||||
height: "36px",
|
||||
textTransform: "none"
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Usage Docs"
|
||||
sx={{
|
||||
color: "#ccc",
|
||||
"&.Mui-selected": { color: "#ccc" },
|
||||
minHeight: "36px",
|
||||
height: "36px",
|
||||
textTransform: "none"
|
||||
}}
|
||||
/>
|
||||
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ padding: 2 }}>
|
||||
{activeTab === 0 && renderConfigFields()}
|
||||
{activeTab === 1 && (
|
||||
<Box sx={{ fontSize: "0.85rem", color: "#aaa" }}>
|
||||
<ReactMarkdown
|
||||
children={nodeData?.usage_documentation || "No usage documentation provided for this node."}
|
||||
components={{
|
||||
h3: ({ node, ...props }) => (
|
||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 1 }} {...props} />
|
||||
),
|
||||
p: ({ node, ...props }) => (
|
||||
<Typography paragraph sx={{ mb: 1.5 }} {...props} />
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul style={{ marginBottom: "1em", paddingLeft: "1.2em" }} {...props} />
|
||||
),
|
||||
li: ({ node, ...props }) => (
|
||||
<li style={{ marginBottom: "0.5em" }} {...props} />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Rename Node Dialog */}
|
||||
<Dialog
|
||||
open={renameOpen}
|
||||
onClose={() => setRenameOpen(false)}
|
||||
PaperProps={{ sx: { bgcolor: "#232323" } }}
|
||||
>
|
||||
<DialogTitle>Rename Node</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
label="Node Title"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
sx={{
|
||||
mt: 1,
|
||||
bgcolor: "#1e1e1e",
|
||||
"& .MuiOutlinedInput-root": {
|
||||
color: "#ccc",
|
||||
backgroundColor: "#1e1e1e",
|
||||
"& fieldset": { borderColor: "#444" }
|
||||
},
|
||||
label: { color: "#aaa" }
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button sx={{ color: "#aaa" }} onClick={() => setRenameOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
sx={{ color: "#58a6ff" }}
|
||||
onClick={() => {
|
||||
// Use selectedNode (passed as prop) or nodeData?.nodeId as fallback
|
||||
const nodeId = selectedNode?.id || nodeData?.nodeId;
|
||||
if (!nodeId) {
|
||||
setRenameOpen(false);
|
||||
return;
|
||||
}
|
||||
effectiveSetNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === nodeId
|
||||
? { ...n, data: { ...n.data, label: renameValue } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
setRenameOpen(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ---- NEW: Accent Color Picker Dialog ---- */}
|
||||
<Dialog
|
||||
open={colorDialogOpen}
|
||||
onClose={() => setColorDialogOpen(false)}
|
||||
PaperProps={{ sx: { bgcolor: "#232323" } }}
|
||||
>
|
||||
<DialogTitle>Pick Node Header/Accent Color</DialogTitle>
|
||||
<DialogContent>
|
||||
<SketchPicker
|
||||
color={accentColor}
|
||||
onChangeComplete={(color) => {
|
||||
const nodeId = selectedNode?.id || nodeData?.nodeId;
|
||||
if (!nodeId) return;
|
||||
const accent = color.hex;
|
||||
const accentDark = darkenColor(accent, 0.7);
|
||||
effectiveSetNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === nodeId
|
||||
? {
|
||||
...n,
|
||||
data: { ...n.data, accentColor: accent },
|
||||
style: {
|
||||
...n.style,
|
||||
"--borealis-accent": accent,
|
||||
"--borealis-accent-dark": accentDark,
|
||||
"--borealis-title": accent,
|
||||
},
|
||||
}
|
||||
: n
|
||||
)
|
||||
);
|
||||
}}
|
||||
disableAlpha
|
||||
presetColors={[
|
||||
"#58a6ff", "#0475c2", "#00d18c", "#ff4f4f", "#ff8c00",
|
||||
"#6b21a8", "#0e7490", "#888", "#fff", "#000"
|
||||
]}
|
||||
/>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
The node's header text and accent gradient will use your selected color.<br />
|
||||
The accent gradient fades to a slightly darker version.
|
||||
</Typography>
|
||||
<Box sx={{ mt: 2, display: "flex", alignItems: "center" }}>
|
||||
<span style={{
|
||||
display: "inline-block",
|
||||
width: 48,
|
||||
height: 22,
|
||||
borderRadius: 4,
|
||||
border: "1px solid #888",
|
||||
background: `linear-gradient(to bottom, ${accentColor} 0%, ${darkenColor(accentColor, 0.7)} 100%)`
|
||||
}} />
|
||||
<span style={{ marginLeft: 10, color: accentColor, fontWeight: "bold" }}>
|
||||
{accentColor}
|
||||
</span>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setColorDialogOpen(false)} sx={{ color: "#aaa" }}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
{/* ---- END ACCENT COLOR PICKER DIALOG ---- */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
260
Data/Server/WebUI/src/Flow_Editor/Node_Sidebar.jsx
Normal file
260
Data/Server/WebUI/src/Flow_Editor/Node_Sidebar.jsx
Normal file
@@ -0,0 +1,260 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Node_Sidebar.jsx
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Button,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Box
|
||||
} from "@mui/material";
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
SaveAlt as SaveAltIcon,
|
||||
Save as SaveIcon,
|
||||
FileOpen as FileOpenIcon,
|
||||
DeleteForever as DeleteForeverIcon,
|
||||
DragIndicator as DragIndicatorIcon,
|
||||
Polyline as PolylineIcon,
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
ChevronRight as ChevronRightIcon
|
||||
} from "@mui/icons-material";
|
||||
import { SaveWorkflowDialog } from "../Dialogs";
|
||||
|
||||
export default function NodeSidebar({
|
||||
categorizedNodes,
|
||||
handleExportFlow,
|
||||
handleImportFlow,
|
||||
handleSaveFlow,
|
||||
handleOpenCloseAllDialog,
|
||||
fileInputRef,
|
||||
onFileInputChange,
|
||||
currentTabName
|
||||
}) {
|
||||
const [expandedCategory, setExpandedCategory] = useState(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [saveName, setSaveName] = useState("");
|
||||
|
||||
const handleAccordionChange = (category) => (_, isExpanded) => {
|
||||
setExpandedCategory(isExpanded ? category : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: collapsed ? 40 : 300,
|
||||
backgroundColor: "#121212",
|
||||
borderRight: "1px solid #333",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%"
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
{!collapsed && (
|
||||
<>
|
||||
{/* Workflows Section */}
|
||||
<Accordion
|
||||
defaultExpanded
|
||||
square
|
||||
disableGutters
|
||||
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
backgroundColor: "#2c2c2c",
|
||||
minHeight: "36px",
|
||||
"& .MuiAccordionSummary-content": { margin: 0 }
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
|
||||
<b>Workflows</b>
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
||||
<Tooltip title="Save Current Flow to Workflows Folder" placement="right" arrow>
|
||||
<Button
|
||||
fullWidth
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={() => {
|
||||
setSaveName(currentTabName || "workflow");
|
||||
setSaveOpen(true);
|
||||
}}
|
||||
sx={buttonStyle}
|
||||
>
|
||||
Save Workflow
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Import JSON File into New Flow Tab" placement="right" arrow>
|
||||
<Button fullWidth startIcon={<FileOpenIcon />} onClick={handleImportFlow} sx={buttonStyle}>
|
||||
Import Workflow (JSON)
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Export Current Tab to a JSON File" placement="right" arrow>
|
||||
<Button fullWidth startIcon={<SaveAltIcon />} onClick={handleExportFlow} sx={buttonStyle}>
|
||||
Export Workflow (JSON)
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Nodes Section */}
|
||||
<Accordion
|
||||
defaultExpanded
|
||||
square
|
||||
disableGutters
|
||||
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
backgroundColor: "#2c2c2c",
|
||||
minHeight: "36px",
|
||||
"& .MuiAccordionSummary-content": { margin: 0 }
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
|
||||
<b>Nodes</b>
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ p: 0 }}>
|
||||
{Object.entries(categorizedNodes).map(([category, items]) => (
|
||||
<Accordion
|
||||
key={category}
|
||||
square
|
||||
expanded={expandedCategory === category}
|
||||
onChange={handleAccordionChange(category)}
|
||||
disableGutters
|
||||
sx={{
|
||||
bgcolor: "#232323",
|
||||
"&:before": { display: "none" },
|
||||
margin: 0,
|
||||
border: 0
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
bgcolor: "#1e1e1e",
|
||||
px: 2,
|
||||
minHeight: "32px",
|
||||
"& .MuiAccordionSummary-content": { margin: 0 }
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: "#888", fontSize: "0.75rem" }}>
|
||||
{category}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 1, py: 0 }}>
|
||||
{items.map((nodeDef) => (
|
||||
<Tooltip
|
||||
key={`${category}-${nodeDef.type}`}
|
||||
title={
|
||||
<span style={{ whiteSpace: "pre-line", wordWrap: "break-word", maxWidth: 220 }}>
|
||||
{nodeDef.description || "Drag & Drop into Editor"}
|
||||
</span>
|
||||
}
|
||||
placement="right"
|
||||
arrow
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
sx={nodeButtonStyle}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData("application/reactflow", nodeDef.type);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
startIcon={<DragIndicatorIcon sx={{ color: "#666", fontSize: 18 }} />}
|
||||
>
|
||||
<span style={{ flexGrow: 1, textAlign: "left" }}>{nodeDef.label}</span>
|
||||
<PolylineIcon sx={{ color: "#58a6ff", fontSize: 18, ml: 1 }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
style={{ display: "none" }}
|
||||
ref={fileInputRef}
|
||||
onChange={onFileInputChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom toggle button */}
|
||||
<Tooltip title={collapsed ? "Expand Sidebar" : "Collapse Sidebar"} placement="left">
|
||||
<Box
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
sx={{
|
||||
height: "36px",
|
||||
borderTop: "1px solid #333",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#888",
|
||||
backgroundColor: "#121212",
|
||||
transition: "background-color 0.2s ease",
|
||||
"&:hover": {
|
||||
backgroundColor: "#1e1e1e"
|
||||
},
|
||||
"&:active": {
|
||||
backgroundColor: "#2a2a2a"
|
||||
}
|
||||
}}
|
||||
>
|
||||
{collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<SaveWorkflowDialog
|
||||
open={saveOpen}
|
||||
value={saveName}
|
||||
onChange={setSaveName}
|
||||
onCancel={() => setSaveOpen(false)}
|
||||
onSave={() => {
|
||||
setSaveOpen(false);
|
||||
handleSaveFlow(saveName);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const buttonStyle = {
|
||||
color: "#ccc",
|
||||
backgroundColor: "#232323",
|
||||
justifyContent: "flex-start",
|
||||
pl: 2,
|
||||
fontSize: "0.9rem",
|
||||
textTransform: "none",
|
||||
"&:hover": {
|
||||
backgroundColor: "#2a2a2a"
|
||||
}
|
||||
};
|
||||
|
||||
const nodeButtonStyle = {
|
||||
color: "#ccc",
|
||||
backgroundColor: "#232323",
|
||||
justifyContent: "space-between",
|
||||
pl: 2,
|
||||
pr: 1,
|
||||
fontSize: "0.9rem",
|
||||
textTransform: "none",
|
||||
"&:hover": {
|
||||
backgroundColor: "#2a2a2a"
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user