Componentized the UI by isolating elements into individual JSX modules.

This commit is contained in:
2025-04-14 19:48:15 -06:00
parent 00a2c83186
commit e1a359169e
4 changed files with 1209 additions and 1131 deletions

View File

@ -1,13 +1,15 @@
// App.jsx
// Core React Imports // Core React Imports
import React, { import React, {
useState, useState,
useEffect, useEffect,
useCallback, useCallback,
useRef useRef
} from "react"; } from "react";
// Material UI - Components // Material UI - Components
import { import {
AppBar, AppBar,
Toolbar, Toolbar,
Typography, Typography,
@ -18,61 +20,46 @@ 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";
// Global Node Update Timer Variable // Import our new components
if (!window.BorealisUpdateRate) { import FlowTabs from "./Flow_Tabs";
import FlowEditor from "./Flow_Editor";
import NodeSidebar from "./Node_Sidebar";
// Global Node Update Timer Variable
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 200; window.BorealisUpdateRate = 200;
} }
// Dynamically load all node components // Dynamically load all node components
const nodeContext = require.context("./nodes", true, /\.jsx$/); const nodeContext = require.context("./nodes", true, /\.jsx$/);
const nodeTypes = {}; const nodeTypes = {};
const categorizedNodes = {}; const categorizedNodes = {};
nodeContext.keys().forEach((path) => { nodeContext.keys().forEach((path) => {
const mod = nodeContext(path); const mod = nodeContext(path);
if (!mod.default) return; if (!mod.default) return;
const { type, label, component } = mod.default; const { type, label, component } = mod.default;
@ -87,189 +74,9 @@ nodeContext.keys().forEach((path) => {
} }
categorizedNodes[category].push(mod.default); categorizedNodes[category].push(mod.default);
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 darkTheme = createTheme({
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({
palette: { palette: {
mode: "dark", mode: "dark",
background: { background: {
@ -280,9 +87,9 @@ const darkTheme = createTheme({
primary: "#ffffff" primary: "#ffffff"
} }
} }
}); });
export default function App() { export default function App() {
const [tabs, setTabs] = useState([ const [tabs, setTabs] = useState([
{ {
id: "flow_1", id: "flow_1",
@ -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>
); );
} }

View 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>
);
}

View 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>
);
}

View 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>
);
}