mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-07-27 10:38:28 -06:00
Componentized the UI by isolating elements into individual JSX modules.
This commit is contained in:
@ -1,3 +1,5 @@
|
|||||||
|
// App.jsx
|
||||||
|
|
||||||
// Core React Imports
|
// Core React Imports
|
||||||
import React, {
|
import React, {
|
||||||
useState,
|
useState,
|
||||||
@ -18,50 +20,35 @@ import {
|
|||||||
CssBaseline,
|
CssBaseline,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
createTheme,
|
createTheme,
|
||||||
Accordion,
|
|
||||||
AccordionSummary,
|
|
||||||
AccordionDetails,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
Divider,
|
Divider,
|
||||||
Tooltip,
|
|
||||||
Tabs,
|
|
||||||
Tab,
|
|
||||||
TextField
|
TextField
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
// Material UI - Icons
|
// Material UI - Icons
|
||||||
import {
|
import {
|
||||||
DragIndicator as DragIndicatorIcon,
|
|
||||||
KeyboardArrowDown as KeyboardArrowDownIcon,
|
KeyboardArrowDown as KeyboardArrowDownIcon,
|
||||||
ExpandMore as ExpandMoreIcon,
|
|
||||||
Save as SaveIcon,
|
|
||||||
FileOpen as FileOpenIcon,
|
|
||||||
DeleteForever as DeleteForeverIcon,
|
|
||||||
InfoOutlined as InfoOutlinedIcon,
|
InfoOutlined as InfoOutlinedIcon,
|
||||||
Polyline as PolylineIcon,
|
|
||||||
MergeType as MergeTypeIcon,
|
MergeType as MergeTypeIcon,
|
||||||
People as PeopleIcon,
|
People as PeopleIcon
|
||||||
Add as AddIcon
|
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
|
|
||||||
// React Flow
|
// React Flow
|
||||||
import ReactFlow, {
|
import { ReactFlowProvider } from "reactflow";
|
||||||
Background,
|
|
||||||
addEdge,
|
|
||||||
applyNodeChanges,
|
|
||||||
applyEdgeChanges,
|
|
||||||
ReactFlowProvider,
|
|
||||||
useReactFlow
|
|
||||||
} from "reactflow";
|
|
||||||
|
|
||||||
// Styles
|
// Styles
|
||||||
import "reactflow/dist/style.css";
|
import "reactflow/dist/style.css";
|
||||||
import "./Borealis.css";
|
import "./Borealis.css";
|
||||||
|
|
||||||
|
// Import our new components
|
||||||
|
import FlowTabs from "./Flow_Tabs";
|
||||||
|
import FlowEditor from "./Flow_Editor";
|
||||||
|
import NodeSidebar from "./Node_Sidebar";
|
||||||
|
|
||||||
// Global Node Update Timer Variable
|
// Global Node Update Timer Variable
|
||||||
if (!window.BorealisUpdateRate) {
|
if (!window.BorealisUpdateRate) {
|
||||||
window.BorealisUpdateRate = 200;
|
window.BorealisUpdateRate = 200;
|
||||||
@ -89,186 +76,6 @@ nodeContext.keys().forEach((path) => {
|
|||||||
nodeTypes[type] = component;
|
nodeTypes[type] = component;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Single flow editor
|
|
||||||
function FlowEditor({ nodes, edges, setNodes, setEdges, nodeTypes }) {
|
|
||||||
const wrapperRef = useRef(null);
|
|
||||||
const { project } = useReactFlow();
|
|
||||||
const [contextMenu, setContextMenu] = useState(null);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
const newNode = {
|
|
||||||
id: id,
|
|
||||||
type: type,
|
|
||||||
position: position,
|
|
||||||
data: {
|
|
||||||
label: nodeMeta?.label || type,
|
|
||||||
content: nodeMeta?.content
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setNodes((nds) => [...nds, newNode]);
|
|
||||||
},
|
|
||||||
[project, setNodes]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDragOver = useCallback((event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = "move";
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onConnect = useCallback(
|
|
||||||
(params) =>
|
|
||||||
setEdges((eds) =>
|
|
||||||
addEdge(
|
|
||||||
{
|
|
||||||
...params,
|
|
||||||
type: "smoothstep",
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRightClick = (e, node) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setContextMenu({
|
|
||||||
mouseX: e.clientX + 2,
|
|
||||||
mouseY: e.clientY - 6,
|
|
||||||
nodeId: node.id
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
|
||||||
if (contextMenu?.nodeId) {
|
|
||||||
setEdges((eds) =>
|
|
||||||
eds.filter(
|
|
||||||
(e) =>
|
|
||||||
e.source !== contextMenu.nodeId &&
|
|
||||||
e.target !== contextMenu.nodeId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveNode = () => {
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const nodeCountEl = document.getElementById("nodeCount");
|
|
||||||
if (nodeCountEl) {
|
|
||||||
nodeCountEl.innerText = nodes.length;
|
|
||||||
}
|
|
||||||
}, [nodes]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flow-editor-container" ref={wrapperRef}>
|
|
||||||
<ReactFlow
|
|
||||||
nodes={nodes}
|
|
||||||
edges={edges}
|
|
||||||
nodeTypes={nodeTypes}
|
|
||||||
onNodesChange={onNodesChange}
|
|
||||||
onEdgesChange={onEdgesChange}
|
|
||||||
onConnect={onConnect}
|
|
||||||
onDrop={onDrop}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onNodeContextMenu={handleRightClick}
|
|
||||||
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
|
|
||||||
edgeOptions={{
|
|
||||||
type: "smoothstep",
|
|
||||||
animated: true,
|
|
||||||
style: {
|
|
||||||
strokeDasharray: "6 3",
|
|
||||||
stroke: "#58a6ff"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
proOptions={{ hideAttribution: true }}
|
|
||||||
>
|
|
||||||
<Background
|
|
||||||
variant="lines"
|
|
||||||
gap={65}
|
|
||||||
size={1}
|
|
||||||
color="rgba(255, 255, 255, 0.2)"
|
|
||||||
/>
|
|
||||||
</ReactFlow>
|
|
||||||
|
|
||||||
{/* Right-click node menu */}
|
|
||||||
<Menu
|
|
||||||
open={Boolean(contextMenu)}
|
|
||||||
onClose={() => setContextMenu(null)}
|
|
||||||
anchorReference="anchorPosition"
|
|
||||||
anchorPosition={
|
|
||||||
contextMenu
|
|
||||||
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
PaperProps={{
|
|
||||||
sx: {
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "13px"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem onClick={handleDisconnect}>
|
|
||||||
<PolylineIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
|
|
||||||
Disconnect All Edges
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={handleRemoveNode}>
|
|
||||||
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
|
|
||||||
Remove Node
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const darkTheme = createTheme({
|
const darkTheme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: "dark",
|
mode: "dark",
|
||||||
@ -293,19 +100,26 @@ export default function App() {
|
|||||||
]);
|
]);
|
||||||
const [activeTabId, setActiveTabId] = useState("flow_1");
|
const [activeTabId, setActiveTabId] = useState("flow_1");
|
||||||
|
|
||||||
|
// About menu
|
||||||
const [aboutAnchorEl, setAboutAnchorEl] = useState(null);
|
const [aboutAnchorEl, setAboutAnchorEl] = useState(null);
|
||||||
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
|
|
||||||
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
|
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
|
|
||||||
|
// Close all flows
|
||||||
|
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
|
||||||
|
|
||||||
|
// Rename tab
|
||||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||||
const [renameTabId, setRenameTabId] = useState(null);
|
const [renameTabId, setRenameTabId] = useState(null);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
|
||||||
|
// Right-click tab menu
|
||||||
const [tabMenuAnchor, setTabMenuAnchor] = useState(null);
|
const [tabMenuAnchor, setTabMenuAnchor] = useState(null);
|
||||||
const [tabMenuTabId, setTabMenuTabId] = useState(null);
|
const [tabMenuTabId, setTabMenuTabId] = useState(null);
|
||||||
|
|
||||||
// Update nodes/edges in a particular tab
|
// File input ref (for imports on older browsers)
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// Setup callbacks to update nodes/edges in the currently active tab
|
||||||
const handleSetNodes = useCallback(
|
const handleSetNodes = useCallback(
|
||||||
(callbackOrArray, tId) => {
|
(callbackOrArray, tId) => {
|
||||||
const targetId = tId || activeTabId;
|
const targetId = tId || activeTabId;
|
||||||
@ -344,7 +158,13 @@ export default function App() {
|
|||||||
const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget);
|
const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget);
|
||||||
const handleAboutMenuClose = () => setAboutAnchorEl(null);
|
const handleAboutMenuClose = () => setAboutAnchorEl(null);
|
||||||
|
|
||||||
// Close all flows
|
// Credits
|
||||||
|
const openCreditsDialog = () => {
|
||||||
|
handleAboutMenuClose();
|
||||||
|
setCreditsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close all dialog
|
||||||
const handleOpenCloseAllDialog = () => setConfirmCloseOpen(true);
|
const handleOpenCloseAllDialog = () => setConfirmCloseOpen(true);
|
||||||
const handleCloseDialog = () => setConfirmCloseOpen(false);
|
const handleCloseDialog = () => setConfirmCloseOpen(false);
|
||||||
const handleConfirmCloseAll = () => {
|
const handleConfirmCloseAll = () => {
|
||||||
@ -376,6 +196,11 @@ export default function App() {
|
|||||||
setActiveTabId(newId);
|
setActiveTabId(newId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle user clicking on a tab
|
||||||
|
const handleTabChange = (newActiveTabId) => {
|
||||||
|
setActiveTabId(newActiveTabId);
|
||||||
|
};
|
||||||
|
|
||||||
// Right-click tab menu
|
// Right-click tab menu
|
||||||
const handleTabRightClick = (evt, tabId) => {
|
const handleTabRightClick = (evt, tabId) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@ -403,9 +228,11 @@ export default function App() {
|
|||||||
const newList = [...old];
|
const newList = [...old];
|
||||||
newList.splice(idx, 1);
|
newList.splice(idx, 1);
|
||||||
|
|
||||||
|
// If we closed the current tab, pick a new active tab
|
||||||
if (tabMenuTabId === activeTabId && newList.length > 0) {
|
if (tabMenuTabId === activeTabId && newList.length > 0) {
|
||||||
setActiveTabId(newList[0].id);
|
setActiveTabId(newList[0].id);
|
||||||
} else if (newList.length === 0) {
|
} else if (newList.length === 0) {
|
||||||
|
// If we closed the only tab, create a fresh one
|
||||||
newList.push({
|
newList.push({
|
||||||
id: "flow_1",
|
id: "flow_1",
|
||||||
tab_name: "Flow 1",
|
tab_name: "Flow 1",
|
||||||
@ -418,6 +245,7 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
handleCloseTabMenu();
|
handleCloseTabMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameDialogSave = () => {
|
const handleRenameDialogSave = () => {
|
||||||
if (!renameTabId) {
|
if (!renameTabId) {
|
||||||
setRenameDialogOpen(false);
|
setRenameDialogOpen(false);
|
||||||
@ -438,6 +266,7 @@ export default function App() {
|
|||||||
const activeTab = tabs.find((x) => x.id === activeTabId);
|
const activeTab = tabs.find((x) => x.id === activeTabId);
|
||||||
if (!activeTab) return;
|
if (!activeTab) return;
|
||||||
|
|
||||||
|
// Build JSON data from the active tab
|
||||||
const data = JSON.stringify(
|
const data = JSON.stringify(
|
||||||
{
|
{
|
||||||
nodes: activeTab.nodes,
|
nodes: activeTab.nodes,
|
||||||
@ -449,10 +278,18 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const blob = new Blob([data], { type: "application/json" });
|
const blob = new Blob([data], { type: "application/json" });
|
||||||
|
|
||||||
|
// Suggested filename based on the tab name
|
||||||
|
// e.g. "Nicole Work Flow" => "nicole_work_flow_workflow.json"
|
||||||
|
const sanitizedTabName = activeTab.tab_name
|
||||||
|
.replace(/\s+/g, "_")
|
||||||
|
.toLowerCase();
|
||||||
|
const suggestedFilename = sanitizedTabName + "_workflow.json";
|
||||||
|
|
||||||
|
// Check if showSaveFilePicker is available (Chrome/Edge)
|
||||||
if (window.showSaveFilePicker) {
|
if (window.showSaveFilePicker) {
|
||||||
try {
|
try {
|
||||||
const fileHandle = await window.showSaveFilePicker({
|
const fileHandle = await window.showSaveFilePicker({
|
||||||
suggestedName: "workflow.json",
|
suggestedName: suggestedFilename,
|
||||||
types: [
|
types: [
|
||||||
{
|
{
|
||||||
description: "Workflow JSON File",
|
description: "Workflow JSON File",
|
||||||
@ -460,6 +297,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
const writable = await fileHandle.createWritable();
|
const writable = await fileHandle.createWritable();
|
||||||
await writable.write(blob);
|
await writable.write(blob);
|
||||||
await writable.close();
|
await writable.close();
|
||||||
@ -467,12 +305,15 @@ export default function App() {
|
|||||||
console.error("Save cancelled or failed:", err);
|
console.error("Save cancelled or failed:", err);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Fallback for browsers like Firefox
|
||||||
|
// (Relies on browser settings to ask user where to save)
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = "workflow.json";
|
a.download = suggestedFilename; // e.g. nicole_work_flow_workflow.json
|
||||||
a.style.display = "none";
|
a.style.display = "none";
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
|
// Cleanup
|
||||||
URL.revokeObjectURL(a.href);
|
URL.revokeObjectURL(a.href);
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
}
|
}
|
||||||
@ -499,9 +340,7 @@ export default function App() {
|
|||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: newId,
|
id: newId,
|
||||||
tab_name:
|
tab_name: json.tab_name || "Imported Flow " + (tabs.length + 1),
|
||||||
json.tab_name ||
|
|
||||||
"Imported Flow " + (tabs.length + 1),
|
|
||||||
nodes: json.nodes || [],
|
nodes: json.nodes || [],
|
||||||
edges: json.edges || []
|
edges: json.edges || []
|
||||||
}
|
}
|
||||||
@ -511,6 +350,7 @@ export default function App() {
|
|||||||
console.error("Import cancelled or failed:", err);
|
console.error("Import cancelled or failed:", err);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Fallback for older browsers
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -528,9 +368,7 @@ export default function App() {
|
|||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: newId,
|
id: newId,
|
||||||
tab_name:
|
tab_name: json.tab_name || "Imported Flow " + (tabs.length + 1),
|
||||||
json.tab_name ||
|
|
||||||
"Imported Flow " + (tabs.length + 1),
|
|
||||||
nodes: json.nodes || [],
|
nodes: json.nodes || [],
|
||||||
edges: json.edges || []
|
edges: json.edges || []
|
||||||
}
|
}
|
||||||
@ -541,21 +379,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Tab onChange logic:
|
|
||||||
* If user clicks the plus “tab”, newValue = “__addtab__”.
|
|
||||||
* Otherwise, newValue is an index: setActiveTab accordingly.
|
|
||||||
*/
|
|
||||||
const handleTabChange = (event, newValue) => {
|
|
||||||
if (newValue === "__addtab__") {
|
|
||||||
// Create the new tab
|
|
||||||
createNewTab();
|
|
||||||
} else {
|
|
||||||
// Normal tab index
|
|
||||||
setActiveTabId(tabs[newValue].id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={darkTheme}>
|
<ThemeProvider theme={darkTheme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
@ -586,7 +409,7 @@ export default function App() {
|
|||||||
variant="h6"
|
variant="h6"
|
||||||
sx={{ flexGrow: 1, fontSize: "1rem" }}
|
sx={{ flexGrow: 1, fontSize: "1rem" }}
|
||||||
>
|
>
|
||||||
|
{/* Additional Title/Info if desired */}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -607,10 +430,7 @@ export default function App() {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleAboutMenuClose();
|
handleAboutMenuClose();
|
||||||
window.open(
|
window.open("https://git.bunny-lab.io/Borealis", "_blank");
|
||||||
"https://git.bunny-lab.io/Borealis",
|
|
||||||
"_blank"
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MergeTypeIcon
|
<MergeTypeIcon
|
||||||
@ -622,12 +442,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
Gitea Project
|
Gitea Project
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem onClick={openCreditsDialog}>
|
||||||
onClick={() => {
|
|
||||||
handleAboutMenuClose();
|
|
||||||
setCreditsDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PeopleIcon
|
<PeopleIcon
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
@ -641,259 +456,18 @@ export default function App() {
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexGrow: 1,
|
|
||||||
overflow: "hidden"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<Box
|
<NodeSidebar
|
||||||
sx={{
|
categorizedNodes={categorizedNodes}
|
||||||
width: 320,
|
handleExportFlow={handleExportFlow}
|
||||||
bgcolor: "#121212",
|
handleImportFlow={handleImportFlow}
|
||||||
borderRight: "1px solid #333",
|
handleOpenCloseAllDialog={handleOpenCloseAllDialog}
|
||||||
overflowY: "auto"
|
fileInputRef={fileInputRef}
|
||||||
}}
|
onFileInputChange={handleFileInputChange}
|
||||||
>
|
|
||||||
<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
|
|
||||||
align="left"
|
|
||||||
sx={{
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "#0475c2"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<b>Workflows</b>
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
startIcon={<SaveIcon />}
|
|
||||||
sx={{
|
|
||||||
color: "#ccc",
|
|
||||||
backgroundColor: "#232323",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
pl: 2,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
textTransform: "none",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "#2a2a2a"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={handleExportFlow}
|
|
||||||
>
|
|
||||||
Export Current Flow
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
startIcon={<FileOpenIcon />}
|
|
||||||
sx={{
|
|
||||||
color: "#ccc",
|
|
||||||
backgroundColor: "#232323",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
pl: 2,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
textTransform: "none",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "#2a2a2a"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={handleImportFlow}
|
|
||||||
>
|
|
||||||
Import Flow
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
startIcon={<DeleteForeverIcon />}
|
|
||||||
sx={{
|
|
||||||
color: "#ccc",
|
|
||||||
backgroundColor: "#232323",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
pl: 2,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
textTransform: "none",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "#2a2a2a"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={handleOpenCloseAllDialog}
|
|
||||||
>
|
|
||||||
Close All Flow Tabs
|
|
||||||
</Button>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<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
|
|
||||||
align="left"
|
|
||||||
sx={{
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "#0475c2"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<b>Nodes</b>
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails sx={{ p: 0 }}>
|
|
||||||
{Object.entries(categorizedNodes).map(
|
|
||||||
([category, items]) => (
|
|
||||||
<Box
|
|
||||||
key={category}
|
|
||||||
sx={{
|
|
||||||
mb: 0,
|
|
||||||
bgcolor: "#232323"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Divider
|
|
||||||
sx={{
|
|
||||||
bgcolor: "transparent",
|
|
||||||
px: 2,
|
|
||||||
py: 0.75,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderColor: "#333"
|
|
||||||
}}
|
|
||||||
variant="fullWidth"
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: "#888",
|
|
||||||
fontSize: "0.75rem"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{category}
|
|
||||||
</Typography>
|
|
||||||
</Divider>
|
|
||||||
{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={{
|
|
||||||
color: "#ccc",
|
|
||||||
backgroundColor:
|
|
||||||
"#232323",
|
|
||||||
justifyContent:
|
|
||||||
"space-between",
|
|
||||||
pl: 2,
|
|
||||||
pr: 1,
|
|
||||||
fontSize:
|
|
||||||
"0.9rem",
|
|
||||||
textTransform:
|
|
||||||
"none",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor:
|
|
||||||
"#2a2a2a"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
draggable
|
|
||||||
onDragStart={(
|
|
||||||
event
|
|
||||||
) => {
|
|
||||||
event.dataTransfer.setData(
|
|
||||||
"application/reactflow",
|
|
||||||
nodeDef.type
|
|
||||||
);
|
|
||||||
event.dataTransfer.effectAllowed =
|
|
||||||
"move";
|
|
||||||
}}
|
|
||||||
startIcon={
|
|
||||||
<DragIndicatorIcon
|
|
||||||
sx={{
|
|
||||||
color: "#666",
|
|
||||||
fontSize: 18
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
flexGrow: 1,
|
|
||||||
textAlign:
|
|
||||||
"left"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{nodeDef.label}
|
|
||||||
</Box>
|
|
||||||
<PolylineIcon
|
|
||||||
sx={{
|
|
||||||
color: "#58a6ff",
|
|
||||||
fontSize: 18,
|
|
||||||
ml: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Right content area: tab bar plus flow editors */}
|
{/* Right content: tab bar + flow editors */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@ -902,71 +476,14 @@ export default function App() {
|
|||||||
overflow: "hidden"
|
overflow: "hidden"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Tab bar with special 'add tab' value */}
|
{/* Tab bar */}
|
||||||
<Box
|
<FlowTabs
|
||||||
sx={{
|
tabs={tabs}
|
||||||
display: "flex",
|
activeTabId={activeTabId}
|
||||||
alignItems: "center",
|
onTabChange={handleTabChange}
|
||||||
backgroundColor: "#232323",
|
onAddTab={createNewTab}
|
||||||
borderBottom: "1px solid #333",
|
onTabRightClick={handleTabRightClick}
|
||||||
height: "36px"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs
|
|
||||||
value={(() => {
|
|
||||||
// Return the index of the active tab,
|
|
||||||
// or fallback to -1 if none
|
|
||||||
const idx = tabs.findIndex(
|
|
||||||
(t) => t.id === activeTabId
|
|
||||||
);
|
|
||||||
return idx >= 0 ? idx : 0;
|
|
||||||
})()}
|
|
||||||
onChange={handleTabChange}
|
|
||||||
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) =>
|
|
||||||
handleTabRightClick(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 to detect in onChange */}
|
|
||||||
<Tab
|
|
||||||
icon={<AddIcon />}
|
|
||||||
value="__addtab__"
|
|
||||||
sx={{
|
|
||||||
minHeight: "36px",
|
|
||||||
height: "36px",
|
|
||||||
color: "#58a6ff",
|
|
||||||
textTransform: "none"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* The flow editors themselves */}
|
{/* The flow editors themselves */}
|
||||||
<Box sx={{ flexGrow: 1, position: "relative" }}>
|
<Box sx={{ flexGrow: 1, position: "relative" }}>
|
||||||
@ -979,23 +496,17 @@ export default function App() {
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
display:
|
display: tab.id === activeTabId ? "block" : "none"
|
||||||
tab.id === activeTabId
|
|
||||||
? "block"
|
|
||||||
: "none"
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<FlowEditor
|
<FlowEditor
|
||||||
nodes={tab.nodes}
|
nodes={tab.nodes}
|
||||||
edges={tab.edges}
|
edges={tab.edges}
|
||||||
setNodes={(val) =>
|
setNodes={(val) => handleSetNodes(val, tab.id)}
|
||||||
handleSetNodes(val, tab.id)
|
setEdges={(val) => handleSetEdges(val, tab.id)}
|
||||||
}
|
|
||||||
setEdges={(val) =>
|
|
||||||
handleSetEdges(val, tab.id)
|
|
||||||
}
|
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
categorizedNodes={categorizedNodes}
|
||||||
/>
|
/>
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
</Box>
|
</Box>
|
||||||
@ -1045,8 +556,7 @@ export default function App() {
|
|||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const val = parseInt(
|
const val = parseInt(
|
||||||
document.getElementById("updateRateInput")
|
document.getElementById("updateRateInput")?.value
|
||||||
?.value
|
|
||||||
);
|
);
|
||||||
if (!isNaN(val) && val >= 50) {
|
if (!isNaN(val) && val >= 50) {
|
||||||
window.BorealisUpdateRate = val;
|
window.BorealisUpdateRate = val;
|
||||||
@ -1077,8 +587,7 @@ export default function App() {
|
|||||||
<DialogTitle>Close All Flow Tabs?</DialogTitle>
|
<DialogTitle>Close All Flow Tabs?</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText sx={{ color: "#ccc" }}>
|
<DialogContentText sx={{ color: "#ccc" }}>
|
||||||
This will remove all existing flow tabs and
|
This will remove all existing flow tabs and create a fresh tab named Flow 1.
|
||||||
create a fresh tab named Flow 1.
|
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@ -1188,15 +697,7 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Hidden file input fallback */}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".json,application/json"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
ref={fileInputRef}
|
|
||||||
onChange={handleFileInputChange}
|
|
||||||
/>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
214
Data/WebUI/src/Flow_Editor.jsx
Normal file
214
Data/WebUI/src/Flow_Editor.jsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
// Flow_Editor.jsx
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import ReactFlow, {
|
||||||
|
Background,
|
||||||
|
addEdge,
|
||||||
|
applyNodeChanges,
|
||||||
|
applyEdgeChanges,
|
||||||
|
useReactFlow
|
||||||
|
} from "reactflow";
|
||||||
|
import { Menu, MenuItem } from "@mui/material";
|
||||||
|
import {
|
||||||
|
Polyline as PolylineIcon,
|
||||||
|
DeleteForever as DeleteForeverIcon
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
|
||||||
|
import "reactflow/dist/style.css";
|
||||||
|
import "./Borealis.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single flow editor component.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - nodes
|
||||||
|
* - edges
|
||||||
|
* - setNodes
|
||||||
|
* - setEdges
|
||||||
|
* - nodeTypes
|
||||||
|
* - categorizedNodes (used to find node meta info on drop)
|
||||||
|
*/
|
||||||
|
export default function FlowEditor({
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
nodeTypes,
|
||||||
|
categorizedNodes
|
||||||
|
}) {
|
||||||
|
const wrapperRef = useRef(null);
|
||||||
|
const { project } = useReactFlow();
|
||||||
|
const [contextMenu, setContextMenu] = useState(null);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Find node definition in the categorizedNodes
|
||||||
|
const nodeMeta = Object.values(categorizedNodes)
|
||||||
|
.flat()
|
||||||
|
.find((n) => n.type === type);
|
||||||
|
|
||||||
|
const newNode = {
|
||||||
|
id: id,
|
||||||
|
type: type,
|
||||||
|
position: position,
|
||||||
|
data: {
|
||||||
|
label: nodeMeta?.label || type,
|
||||||
|
content: nodeMeta?.content
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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: "smoothstep",
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRightClick = (e, node) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenu({
|
||||||
|
mouseX: e.clientX + 2,
|
||||||
|
mouseY: e.clientY - 6,
|
||||||
|
nodeId: node.id
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
if (contextMenu?.nodeId) {
|
||||||
|
setEdges((eds) =>
|
||||||
|
eds.filter(
|
||||||
|
(e) =>
|
||||||
|
e.source !== contextMenu.nodeId &&
|
||||||
|
e.target !== contextMenu.nodeId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveNode = () => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nodeCountEl = document.getElementById("nodeCount");
|
||||||
|
if (nodeCountEl) {
|
||||||
|
nodeCountEl.innerText = nodes.length;
|
||||||
|
}
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flow-editor-container" ref={wrapperRef}>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onNodeContextMenu={handleRightClick}
|
||||||
|
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
|
||||||
|
edgeOptions={{
|
||||||
|
type: "smoothstep",
|
||||||
|
animated: true,
|
||||||
|
style: {
|
||||||
|
strokeDasharray: "6 3",
|
||||||
|
stroke: "#58a6ff"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background
|
||||||
|
variant="lines"
|
||||||
|
gap={65}
|
||||||
|
size={1}
|
||||||
|
color="rgba(255, 255, 255, 0.2)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
|
||||||
|
{/* Right-click node menu */}
|
||||||
|
<Menu
|
||||||
|
open={Boolean(contextMenu)}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
anchorReference="anchorPosition"
|
||||||
|
anchorPosition={
|
||||||
|
contextMenu
|
||||||
|
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
bgcolor: "#1e1e1e",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "13px"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={handleDisconnect}>
|
||||||
|
<PolylineIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
|
||||||
|
Disconnect All Edges
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleRemoveNode}>
|
||||||
|
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
|
||||||
|
Remove Node
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
98
Data/WebUI/src/Flow_Tabs.jsx
Normal file
98
Data/WebUI/src/Flow_Tabs.jsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// Flow_Tabs.jsx
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Box, Tabs, Tab } 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 */}
|
||||||
|
<Tab
|
||||||
|
icon={<AddIcon />}
|
||||||
|
value="__addtab__"
|
||||||
|
sx={{
|
||||||
|
minHeight: "36px",
|
||||||
|
height: "36px",
|
||||||
|
color: "#58a6ff",
|
||||||
|
textTransform: "none"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
265
Data/WebUI/src/Node_Sidebar.jsx
Normal file
265
Data/WebUI/src/Node_Sidebar.jsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
// Node_Sidebar.jsx
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Tooltip,
|
||||||
|
Typography
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
ExpandMore as ExpandMoreIcon,
|
||||||
|
Save as SaveIcon,
|
||||||
|
FileOpen as FileOpenIcon,
|
||||||
|
DeleteForever as DeleteForeverIcon,
|
||||||
|
DragIndicator as DragIndicatorIcon,
|
||||||
|
Polyline as PolylineIcon
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left sidebar for managing workflows and node categories.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - categorizedNodes (object of arrays, e.g. { "Category": [{...}, ...], ... })
|
||||||
|
* - handleExportFlow() => void
|
||||||
|
* - handleImportFlow() => void
|
||||||
|
* - handleOpenCloseAllDialog() => void
|
||||||
|
* - fileInputRef (ref to hidden file input)
|
||||||
|
* - onFileInputChange(event) => void
|
||||||
|
*/
|
||||||
|
export default function NodeSidebar({
|
||||||
|
categorizedNodes,
|
||||||
|
handleExportFlow,
|
||||||
|
handleImportFlow,
|
||||||
|
handleOpenCloseAllDialog,
|
||||||
|
fileInputRef,
|
||||||
|
onFileInputChange
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 320,
|
||||||
|
backgroundColor: "#121212",
|
||||||
|
borderRight: "1px solid #333",
|
||||||
|
overflowY: "auto"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
align="left"
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "#0475c2"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<b>Workflows</b>
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
sx={{
|
||||||
|
color: "#ccc",
|
||||||
|
backgroundColor: "#232323",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
pl: 2,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
textTransform: "none",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "#2a2a2a"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={handleExportFlow}
|
||||||
|
>
|
||||||
|
Export Current Flow
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
startIcon={<FileOpenIcon />}
|
||||||
|
sx={{
|
||||||
|
color: "#ccc",
|
||||||
|
backgroundColor: "#232323",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
pl: 2,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
textTransform: "none",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "#2a2a2a"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={handleImportFlow}
|
||||||
|
>
|
||||||
|
Import Flow
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
startIcon={<DeleteForeverIcon />}
|
||||||
|
sx={{
|
||||||
|
color: "#ccc",
|
||||||
|
backgroundColor: "#232323",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
pl: 2,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
textTransform: "none",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "#2a2a2a"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={handleOpenCloseAllDialog}
|
||||||
|
>
|
||||||
|
Close All Flows
|
||||||
|
</Button>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<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
|
||||||
|
align="left"
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "#0475c2"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<b>Nodes</b>
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ p: 0 }}>
|
||||||
|
{Object.entries(categorizedNodes).map(([category, items]) => (
|
||||||
|
<div key={category} style={{ marginBottom: 0, backgroundColor: "#232323" }}>
|
||||||
|
<Divider
|
||||||
|
sx={{
|
||||||
|
bgcolor: "transparent",
|
||||||
|
px: 2,
|
||||||
|
py: 0.75,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderColor: "#333"
|
||||||
|
}}
|
||||||
|
variant="fullWidth"
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "#888",
|
||||||
|
fontSize: "0.75rem"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</Typography>
|
||||||
|
</Divider>
|
||||||
|
{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={{
|
||||||
|
color: "#ccc",
|
||||||
|
backgroundColor: "#232323",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
pl: 2,
|
||||||
|
pr: 1,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
textTransform: "none",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "#2a2a2a"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
{/* Hidden file input fallback for older browsers */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={onFileInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user