Overhauled Deployment Structure
This commit is contained in:
460
Data/Server/WebUI/src/App.jsx
Normal file
460
Data/Server/WebUI/src/App.jsx
Normal file
@ -0,0 +1,460 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/App.jsx
|
||||
|
||||
// Core React Imports
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef
|
||||
} from "react";
|
||||
|
||||
// Material UI - Components
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Box,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Button,
|
||||
CssBaseline,
|
||||
ThemeProvider,
|
||||
createTheme
|
||||
} from "@mui/material";
|
||||
|
||||
// Material UI - Icons
|
||||
import {
|
||||
KeyboardArrowDown as KeyboardArrowDownIcon,
|
||||
InfoOutlined as InfoOutlinedIcon,
|
||||
MergeType as MergeTypeIcon,
|
||||
People as PeopleIcon
|
||||
} from "@mui/icons-material";
|
||||
|
||||
// React Flow
|
||||
import { ReactFlowProvider } from "reactflow";
|
||||
|
||||
// Styles
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
// Import Borealis Modules
|
||||
import FlowTabs from "./Flow_Tabs";
|
||||
import FlowEditor from "./Flow_Editor";
|
||||
import NodeSidebar from "./Node_Sidebar";
|
||||
import {
|
||||
CloseAllDialog,
|
||||
CreditsDialog,
|
||||
RenameTabDialog,
|
||||
TabContextMenu
|
||||
} from "./Dialogs";
|
||||
import StatusBar from "./Status_Bar";
|
||||
|
||||
// Websocket Functionality
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
if (!window.BorealisSocket) {
|
||||
window.BorealisSocket = io(window.location.origin, {
|
||||
transports: ["websocket"]
|
||||
});
|
||||
}
|
||||
|
||||
// Global Node Update Timer Variable
|
||||
if (!window.BorealisUpdateRate) {
|
||||
window.BorealisUpdateRate = 200;
|
||||
}
|
||||
|
||||
// Dynamically load all node components
|
||||
const nodeContext = require.context("./nodes", true, /\.jsx$/);
|
||||
const nodeTypes = {};
|
||||
const categorizedNodes = {};
|
||||
|
||||
nodeContext.keys().forEach((path) => {
|
||||
const mod = nodeContext(path);
|
||||
if (!mod.default) return;
|
||||
const { type, label, component } = mod.default;
|
||||
if (!type || !component) return;
|
||||
|
||||
const pathParts = path.replace("./", "").split("/");
|
||||
if (pathParts.length < 2) return;
|
||||
const category = pathParts[0];
|
||||
|
||||
if (!categorizedNodes[category]) {
|
||||
categorizedNodes[category] = [];
|
||||
}
|
||||
categorizedNodes[category].push(mod.default);
|
||||
nodeTypes[type] = component;
|
||||
});
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
mode: "dark",
|
||||
background: {
|
||||
default: "#121212",
|
||||
paper: "#1e1e1e"
|
||||
},
|
||||
text: {
|
||||
primary: "#ffffff"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const [tabs, setTabs] = useState([
|
||||
{
|
||||
id: "flow_1",
|
||||
tab_name: "Flow 1",
|
||||
nodes: [],
|
||||
edges: []
|
||||
}
|
||||
]);
|
||||
const [activeTabId, setActiveTabId] = useState("flow_1");
|
||||
|
||||
const [aboutAnchorEl, setAboutAnchorEl] = useState(null);
|
||||
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
|
||||
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [renameTabId, setRenameTabId] = useState(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [tabMenuAnchor, setTabMenuAnchor] = useState(null);
|
||||
const [tabMenuTabId, setTabMenuTabId] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleSetNodes = useCallback(
|
||||
(callbackOrArray, tId) => {
|
||||
const targetId = tId || activeTabId;
|
||||
setTabs((old) =>
|
||||
old.map((tab) => {
|
||||
if (tab.id !== targetId) return tab;
|
||||
const newNodes =
|
||||
typeof callbackOrArray === "function"
|
||||
? callbackOrArray(tab.nodes)
|
||||
: callbackOrArray;
|
||||
return { ...tab, nodes: newNodes };
|
||||
})
|
||||
);
|
||||
},
|
||||
[activeTabId]
|
||||
);
|
||||
|
||||
const handleSetEdges = useCallback(
|
||||
(callbackOrArray, tId) => {
|
||||
const targetId = tId || activeTabId;
|
||||
setTabs((old) =>
|
||||
old.map((tab) => {
|
||||
if (tab.id !== targetId) return tab;
|
||||
const newEdges =
|
||||
typeof callbackOrArray === "function"
|
||||
? callbackOrArray(tab.edges)
|
||||
: callbackOrArray;
|
||||
return { ...tab, edges: newEdges };
|
||||
})
|
||||
);
|
||||
},
|
||||
[activeTabId]
|
||||
);
|
||||
|
||||
const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget);
|
||||
const handleAboutMenuClose = () => setAboutAnchorEl(null);
|
||||
const openCreditsDialog = () => {
|
||||
handleAboutMenuClose();
|
||||
setCreditsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenCloseAllDialog = () => setConfirmCloseOpen(true);
|
||||
const handleCloseDialog = () => setConfirmCloseOpen(false);
|
||||
const handleConfirmCloseAll = () => {
|
||||
setTabs([
|
||||
{
|
||||
id: "flow_1",
|
||||
tab_name: "Flow 1",
|
||||
nodes: [],
|
||||
edges: []
|
||||
}
|
||||
]);
|
||||
setActiveTabId("flow_1");
|
||||
setConfirmCloseOpen(false);
|
||||
};
|
||||
|
||||
const createNewTab = () => {
|
||||
const nextIndex = tabs.length + 1;
|
||||
const newId = "flow_" + nextIndex;
|
||||
setTabs((old) => [
|
||||
...old,
|
||||
{
|
||||
id: newId,
|
||||
tab_name: "Flow " + nextIndex,
|
||||
nodes: [],
|
||||
edges: []
|
||||
}
|
||||
]);
|
||||
setActiveTabId(newId);
|
||||
};
|
||||
|
||||
const handleTabChange = (newActiveTabId) => {
|
||||
setActiveTabId(newActiveTabId);
|
||||
};
|
||||
|
||||
const handleTabRightClick = (evt, tabId) => {
|
||||
evt.preventDefault();
|
||||
setTabMenuAnchor({ x: evt.clientX, y: evt.clientY });
|
||||
setTabMenuTabId(tabId);
|
||||
};
|
||||
|
||||
const handleCloseTabMenu = () => {
|
||||
setTabMenuAnchor(null);
|
||||
setTabMenuTabId(null);
|
||||
};
|
||||
|
||||
const handleRenameTab = () => {
|
||||
setRenameDialogOpen(true);
|
||||
setRenameTabId(tabMenuTabId);
|
||||
const t = tabs.find((x) => x.id === tabMenuTabId);
|
||||
setRenameValue(t ? t.tab_name : "");
|
||||
handleCloseTabMenu();
|
||||
};
|
||||
|
||||
const handleCloseTab = () => {
|
||||
setTabs((old) => {
|
||||
const idx = old.findIndex((t) => t.id === tabMenuTabId);
|
||||
if (idx === -1) return old;
|
||||
|
||||
const newList = [...old];
|
||||
newList.splice(idx, 1);
|
||||
|
||||
if (tabMenuTabId === activeTabId && newList.length > 0) {
|
||||
setActiveTabId(newList[0].id);
|
||||
} else if (newList.length === 0) {
|
||||
newList.push({
|
||||
id: "flow_1",
|
||||
tab_name: "Flow 1",
|
||||
nodes: [],
|
||||
edges: []
|
||||
});
|
||||
setActiveTabId("flow_1");
|
||||
}
|
||||
return newList;
|
||||
});
|
||||
handleCloseTabMenu();
|
||||
};
|
||||
|
||||
const handleRenameDialogSave = () => {
|
||||
if (!renameTabId) {
|
||||
setRenameDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
setTabs((old) =>
|
||||
old.map((tab) =>
|
||||
tab.id === renameTabId
|
||||
? { ...tab, tab_name: renameValue }
|
||||
: tab
|
||||
)
|
||||
);
|
||||
setRenameDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleExportFlow = async () => {
|
||||
const activeTab = tabs.find((x) => x.id === activeTabId);
|
||||
if (!activeTab) return;
|
||||
|
||||
const data = JSON.stringify(
|
||||
{
|
||||
nodes: activeTab.nodes,
|
||||
edges: activeTab.edges,
|
||||
tab_name: activeTab.tab_name
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
const blob = new Blob([data], { type: "application/json" });
|
||||
const sanitizedTabName = activeTab.tab_name.replace(/\s+/g, "_").toLowerCase();
|
||||
const suggestedFilename = sanitizedTabName + "_workflow.json";
|
||||
|
||||
if (window.showSaveFilePicker) {
|
||||
try {
|
||||
const fileHandle = await window.showSaveFilePicker({
|
||||
suggestedName: suggestedFilename,
|
||||
types: [
|
||||
{
|
||||
description: "Workflow JSON File",
|
||||
accept: { "application/json": [".json"] }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
} catch (err) {
|
||||
console.error("Save cancelled or failed:", err);
|
||||
}
|
||||
} else {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = suggestedFilename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFlow = async () => {
|
||||
if (window.showOpenFilePicker) {
|
||||
try {
|
||||
const [fileHandle] = await window.showOpenFilePicker({
|
||||
types: [
|
||||
{
|
||||
description: "Workflow JSON File",
|
||||
accept: { "application/json": [".json"] }
|
||||
}
|
||||
]
|
||||
});
|
||||
const file = await fileHandle.getFile();
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
|
||||
const newId = "flow_" + (tabs.length + 1);
|
||||
setTabs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: newId,
|
||||
tab_name: json.tab_name || "Imported Flow " + (tabs.length + 1),
|
||||
nodes: json.nodes || [],
|
||||
edges: json.edges || []
|
||||
}
|
||||
]);
|
||||
setActiveTabId(newId);
|
||||
} catch (err) {
|
||||
console.error("Import cancelled or failed:", err);
|
||||
}
|
||||
} else {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInputChange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
|
||||
const newId = "flow_" + (tabs.length + 1);
|
||||
setTabs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: newId,
|
||||
tab_name: json.tab_name || "Imported Flow " + (tabs.length + 1),
|
||||
nodes: json.nodes || [],
|
||||
edges: json.edges || []
|
||||
}
|
||||
]);
|
||||
setActiveTabId(newId);
|
||||
} catch (err) {
|
||||
console.error("Failed to read file:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline />
|
||||
|
||||
<Box sx={{ width: "100vw", height: "100vh", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<AppBar position="static" sx={{ bgcolor: "#16191d" }}>
|
||||
<Toolbar sx={{ minHeight: "36px" }}>
|
||||
<Box component="img" src="/Borealis_Logo_Full.png" alt="Borealis Logo" sx={{ height: "52px", marginRight: "8px" }} />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, fontSize: "1rem" }}></Typography>
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={handleAboutMenuOpen}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
startIcon={<InfoOutlinedIcon />}
|
||||
sx={{ height: "36px" }}
|
||||
>
|
||||
About
|
||||
</Button>
|
||||
<Menu anchorEl={aboutAnchorEl} open={Boolean(aboutAnchorEl)} onClose={handleAboutMenuClose}>
|
||||
<MenuItem onClick={() => { handleAboutMenuClose(); window.open("https://git.bunny-lab.io/Borealis", "_blank"); }}>
|
||||
<MergeTypeIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} /> Gitea Project
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openCreditsDialog}>
|
||||
<PeopleIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} /> Credits
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
|
||||
<NodeSidebar
|
||||
categorizedNodes={categorizedNodes}
|
||||
handleExportFlow={handleExportFlow}
|
||||
handleImportFlow={handleImportFlow}
|
||||
handleOpenCloseAllDialog={handleOpenCloseAllDialog}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileInputChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
|
||||
<FlowTabs
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onTabChange={handleTabChange}
|
||||
onAddTab={createNewTab}
|
||||
onTabRightClick={handleTabRightClick}
|
||||
/>
|
||||
|
||||
<Box sx={{ flexGrow: 1, position: "relative" }}>
|
||||
{tabs.map((tab) => (
|
||||
<Box
|
||||
key={tab.id}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: tab.id === activeTabId ? "block" : "none"
|
||||
}}
|
||||
>
|
||||
<ReactFlowProvider>
|
||||
<FlowEditor
|
||||
nodes={tab.nodes}
|
||||
edges={tab.edges}
|
||||
setNodes={(val) => handleSetNodes(val, tab.id)}
|
||||
setEdges={(val) => handleSetEdges(val, tab.id)}
|
||||
nodeTypes={nodeTypes}
|
||||
categorizedNodes={categorizedNodes}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<StatusBar />
|
||||
</Box>
|
||||
|
||||
<CloseAllDialog
|
||||
open={confirmCloseOpen}
|
||||
onClose={handleCloseDialog}
|
||||
onConfirm={handleConfirmCloseAll}
|
||||
/>
|
||||
<CreditsDialog open={creditsDialogOpen} onClose={() => setCreditsDialogOpen(false)} />
|
||||
<RenameTabDialog
|
||||
open={renameDialogOpen}
|
||||
value={renameValue}
|
||||
onChange={setRenameValue}
|
||||
onCancel={() => setRenameDialogOpen(false)}
|
||||
onSave={handleRenameDialogSave}
|
||||
/>
|
||||
<TabContextMenu
|
||||
anchor={tabMenuAnchor}
|
||||
onClose={handleCloseTabMenu}
|
||||
onRename={handleRenameTab}
|
||||
onCloseTab={handleCloseTab}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
166
Data/Server/WebUI/src/Borealis.css
Normal file
166
Data/Server/WebUI/src/Borealis.css
Normal file
@ -0,0 +1,166 @@
|
||||
/* ///////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Borealis.css
|
||||
|
||||
/* ======================================= */
|
||||
/* FLOW EDITOR */
|
||||
/* ======================================= */
|
||||
|
||||
/* FlowEditor background container */
|
||||
.flow-editor-container {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Blue Gradient Overlay */
|
||||
.flow-editor-container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(9, 44, 68, 0.9) 0%,
|
||||
rgba(30, 30, 30, 0) 45%,
|
||||
rgba(30, 30, 30, 0) 75%,
|
||||
rgba(9, 44, 68, 0.7) 100%
|
||||
);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* ======================================= */
|
||||
/* NODE SIDEBAR */
|
||||
/* ======================================= */
|
||||
|
||||
/* Emphasize Drag & Drop Node Functionality */
|
||||
.sidebar-button:hover {
|
||||
background-color: #2a2a2a !important;
|
||||
box-shadow: 0 0 5px rgba(88, 166, 255, 0.3);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* ======================================= */
|
||||
/* NODES */
|
||||
/* ======================================= */
|
||||
|
||||
/* Borealis Node Styling */
|
||||
.borealis-node {
|
||||
background: #2c2c2c;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
min-width: 160px;
|
||||
max-width: 260px;
|
||||
position: relative;
|
||||
box-shadow: 0 0 5px rgba(88, 166, 255, 0.15),
|
||||
0 0 10px rgba(88, 166, 255, 0.15);
|
||||
transition: box-shadow 0.3s ease-in-out;
|
||||
}
|
||||
.borealis-node-header {
|
||||
background: #232323;
|
||||
padding: 6px 10px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
font-weight: bold;
|
||||
color: #58a6ff;
|
||||
font-size: 10px;
|
||||
}
|
||||
.borealis-node-content {
|
||||
padding: 10px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.borealis-handle {
|
||||
background: #58a6ff;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* Global dark form inputs */
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
background-color: #2a2a2a;
|
||||
color: #ccc;
|
||||
border: 1px solid #444;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Label / Dark Text styling */
|
||||
label {
|
||||
color: #aaa;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ======================================= */
|
||||
/* FLOW TABS */
|
||||
/* ======================================= */
|
||||
|
||||
/* Multi-Tab Bar Adjustments */
|
||||
.MuiTabs-root {
|
||||
min-height: 32px !important;
|
||||
}
|
||||
|
||||
.MuiTab-root {
|
||||
min-height: 32px !important;
|
||||
padding: 6px 12px !important;
|
||||
color: #58a6ff !important;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
/* Highlight tab on hover if it's not active */
|
||||
.MuiTab-root:hover:not(.Mui-selected) {
|
||||
background-color: #2C2C2C !important;
|
||||
}
|
||||
|
||||
/* We rely on the TabIndicatorProps to show the underline highlight for active tabs. */
|
||||
|
||||
/* ======================================= */
|
||||
/* REACT-SIMPLE-KEYBOARD */
|
||||
/* ======================================= */
|
||||
|
||||
/* keyboard-dark-theme.css */
|
||||
|
||||
/* react-simple-keyboard dark theming */
|
||||
.hg-theme-dark {
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.hg-button {
|
||||
background: #2c2c2c;
|
||||
color: #ccc;
|
||||
border: 1px solid #444;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hg-button:hover {
|
||||
background: #58a6ff;
|
||||
color: #000;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.borealis-keyboard .hg-button.hg-standardBtn {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Force rows to appear horizontally */
|
||||
.simple-keyboard-main .hg-row {
|
||||
display: flex !important;
|
||||
flex-flow: row wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Slight spacing around each key (optional) */
|
||||
.simple-keyboard-main .hg-row .hg-button {
|
||||
margin: 3px !important;
|
||||
}
|
||||
|
||||
/* Keep the entire keyboard from shrinking or going vertical */
|
||||
.simple-keyboard-main {
|
||||
display: inline-block !important;
|
||||
width: auto !important;
|
||||
max-width: 1000px; /* or whatever max width you like */
|
||||
}
|
105
Data/Server/WebUI/src/Dialogs.jsx
Normal file
105
Data/Server/WebUI/src/Dialogs.jsx
Normal file
@ -0,0 +1,105 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Dialogs.jsx
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField
|
||||
} from "@mui/material";
|
||||
|
||||
export function CloseAllDialog({ open, onClose, onConfirm }) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
||||
<DialogTitle>Close All Flow Tabs?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ color: "#ccc" }}>
|
||||
This will remove all existing flow tabs and create a fresh tab named Flow 1.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||
<Button onClick={onConfirm} sx={{ color: "#ff4f4f" }}>Close All</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreditsDialog({ open, onClose }) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
||||
<DialogTitle>Borealis Workflow Automation Tool</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ color: "#ccc" }}>
|
||||
Designed by Nicole Rappe @ Bunny Lab
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} sx={{ color: "#58a6ff" }}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenameTabDialog({ open, value, onChange, onCancel, onSave }) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
||||
<DialogTitle>Rename Tab</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Tab Name"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#2a2a2a",
|
||||
color: "#ccc",
|
||||
"& fieldset": {
|
||||
borderColor: "#444"
|
||||
},
|
||||
"&:hover fieldset": {
|
||||
borderColor: "#666"
|
||||
}
|
||||
},
|
||||
label: { color: "#aaa" },
|
||||
mt: 1
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabContextMenu({ anchor, onClose, onRename, onCloseTab }) {
|
||||
return (
|
||||
<Menu
|
||||
open={Boolean(anchor)}
|
||||
onClose={onClose}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={anchor ? { top: anchor.y, left: anchor.x } : undefined}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: "#1e1e1e",
|
||||
color: "#fff",
|
||||
fontSize: "13px"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={onRename}>Rename</MenuItem>
|
||||
<MenuItem onClick={onCloseTab}>Close</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
}
|
213
Data/Server/WebUI/src/Flow_Editor.jsx
Normal file
213
Data/Server/WebUI/src/Flow_Editor.jsx
Normal file
@ -0,0 +1,213 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/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";
|
||||
|
||||
/**
|
||||
* 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/Server/WebUI/src/Flow_Tabs.jsx
Normal file
98
Data/Server/WebUI/src/Flow_Tabs.jsx
Normal file
@ -0,0 +1,98 @@
|
||||
////////// 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 } 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>
|
||||
);
|
||||
}
|
191
Data/Server/WebUI/src/Node_Sidebar.jsx
Normal file
191
Data/Server/WebUI/src/Node_Sidebar.jsx
Normal file
@ -0,0 +1,191 @@
|
||||
////////// 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
|
||||
} 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";
|
||||
|
||||
export default function NodeSidebar({
|
||||
categorizedNodes,
|
||||
handleExportFlow,
|
||||
handleImportFlow,
|
||||
handleOpenCloseAllDialog,
|
||||
fileInputRef,
|
||||
onFileInputChange
|
||||
}) {
|
||||
const [expandedCategory, setExpandedCategory] = useState(null);
|
||||
|
||||
const handleAccordionChange = (category) => (_, isExpanded) => {
|
||||
setExpandedCategory(isExpanded ? category : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 300, //Width of the Node Sidebar
|
||||
backgroundColor: "#121212",
|
||||
borderRight: "1px solid #333",
|
||||
overflowY: "auto"
|
||||
}}
|
||||
>
|
||||
{/* 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" }}>
|
||||
<Button fullWidth startIcon={<SaveIcon />} onClick={handleExportFlow} sx={buttonStyle}>
|
||||
Export Current Flow
|
||||
</Button>
|
||||
<Button fullWidth startIcon={<FileOpenIcon />} onClick={handleImportFlow} sx={buttonStyle}>
|
||||
Import Flow
|
||||
</Button>
|
||||
<Button fullWidth startIcon={<DeleteForeverIcon />} onClick={handleOpenCloseAllDialog} sx={buttonStyle}>
|
||||
Close All Flows
|
||||
</Button>
|
||||
</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 fallback for older browsers */}
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
style={{ display: "none" }}
|
||||
ref={fileInputRef}
|
||||
onChange={onFileInputChange}
|
||||
/>
|
||||
</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"
|
||||
}
|
||||
};
|
71
Data/Server/WebUI/src/Status_Bar.jsx
Normal file
71
Data/Server/WebUI/src/Status_Bar.jsx
Normal file
@ -0,0 +1,71 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Status_Bar.jsx
|
||||
|
||||
import React from "react";
|
||||
import { Box, Button, Divider } from "@mui/material";
|
||||
|
||||
export default function StatusBar() {
|
||||
const applyRate = () => {
|
||||
const val = parseInt(
|
||||
document.getElementById("updateRateInput")?.value
|
||||
);
|
||||
if (!isNaN(val) && val >= 50) {
|
||||
window.BorealisUpdateRate = val;
|
||||
console.log("Global update rate set to", val + "ms");
|
||||
} else {
|
||||
alert("Please enter a valid number (min 50).");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
bgcolor: "#1e1e1e",
|
||||
color: "white",
|
||||
px: 2,
|
||||
py: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<b>Nodes</b>: <span id="nodeCount">0</span>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ borderColor: "#444" }}
|
||||
/>
|
||||
<b>Update Rate (ms):</b>
|
||||
<input
|
||||
id="updateRateInput"
|
||||
type="number"
|
||||
min="50"
|
||||
step="50"
|
||||
defaultValue={window.BorealisUpdateRate}
|
||||
style={{
|
||||
width: "80px",
|
||||
background: "#121212",
|
||||
color: "#fff",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "3px",
|
||||
padding: "3px",
|
||||
fontSize: "0.8rem"
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={applyRate}
|
||||
sx={{
|
||||
color: "#58a6ff",
|
||||
borderColor: "#58a6ff",
|
||||
fontSize: "0.75rem",
|
||||
textTransform: "none",
|
||||
px: 1.5
|
||||
}}
|
||||
>
|
||||
Apply Rate
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
18
Data/Server/WebUI/src/index.js
Normal file
18
Data/Server/WebUI/src/index.js
Normal file
@ -0,0 +1,18 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/index.js
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
// Global Styles
|
||||
import './index.css';
|
||||
import "normalize.css/normalize.css";
|
||||
import './Borealis.css'; // Global Theming for All of Borealis
|
||||
|
||||
import App from './App.jsx';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
154
Data/Server/WebUI/src/nodes/Agents/Node_Agent.jsx
Normal file
154
Data/Server/WebUI/src/nodes/Agents/Node_Agent.jsx
Normal file
@ -0,0 +1,154 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Agent/Node_Agent.jsx
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
||||
|
||||
const BorealisAgentNode = ({ id, data }) => {
|
||||
const { getNodes, getEdges, setNodes } = useReactFlow();
|
||||
const [agents, setAgents] = useState([]);
|
||||
const [selectedAgent, setSelectedAgent] = useState(data.agent_id || "");
|
||||
|
||||
// -------------------------------
|
||||
// Load agent list from backend
|
||||
// -------------------------------
|
||||
useEffect(() => {
|
||||
fetch("/api/agents")
|
||||
.then(res => res.json())
|
||||
.then(setAgents);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetch("/api/agents")
|
||||
.then(res => res.json())
|
||||
.then(setAgents);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// -------------------------------
|
||||
// Helper: Get all provisioner role nodes connected to bottom port
|
||||
// -------------------------------
|
||||
const getAttachedProvisioners = () => {
|
||||
const allNodes = getNodes();
|
||||
const allEdges = getEdges();
|
||||
const attached = [];
|
||||
|
||||
for (const edge of allEdges) {
|
||||
if (edge.source === id && edge.sourceHandle === "provisioner") {
|
||||
const roleNode = allNodes.find(n => n.id === edge.target);
|
||||
if (roleNode && typeof window.__BorealisInstructionNodes?.[roleNode.id] === "function") {
|
||||
attached.push(window.__BorealisInstructionNodes[roleNode.id]());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return attached;
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
// Provision Agent with all Roles
|
||||
// -------------------------------
|
||||
const handleProvision = () => {
|
||||
if (!selectedAgent) return;
|
||||
|
||||
const provisionRoles = getAttachedProvisioners();
|
||||
if (!provisionRoles.length) {
|
||||
console.warn("No provisioner nodes connected to agent.");
|
||||
return;
|
||||
}
|
||||
|
||||
const configPayload = {
|
||||
agent_id: selectedAgent,
|
||||
roles: provisionRoles
|
||||
};
|
||||
|
||||
fetch("/api/agent/provision", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(configPayload)
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(() => {
|
||||
console.log(`[Provision] Agent ${selectedAgent} updated with ${provisionRoles.length} roles.`);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="borealis-node">
|
||||
{/* This bottom port is used for bi-directional provisioning & feedback */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="provisioner"
|
||||
className="borealis-handle"
|
||||
style={{ top: "100%", background: "#58a6ff" }}
|
||||
/>
|
||||
|
||||
<div className="borealis-node-header">Borealis Agent</div>
|
||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
||||
<label>Agent:</label>
|
||||
<select
|
||||
value={selectedAgent}
|
||||
onChange={(e) => {
|
||||
const newId = e.target.value;
|
||||
setSelectedAgent(newId);
|
||||
setNodes(nds =>
|
||||
nds.map(n =>
|
||||
n.id === id
|
||||
? { ...n, data: { ...n.data, agent_id: newId } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}}
|
||||
style={{ width: "100%", marginBottom: "6px", fontSize: "9px" }}
|
||||
>
|
||||
<option value="">-- Select --</option>
|
||||
{Object.entries(agents).map(([id, info]) => {
|
||||
const label = info.status === "provisioned" ? "(Provisioned)" : "(Idle)";
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{id} {label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleProvision}
|
||||
style={{ width: "100%", fontSize: "9px", padding: "4px", marginTop: "4px" }}
|
||||
>
|
||||
Provision Agent
|
||||
</button>
|
||||
|
||||
<hr style={{ margin: "6px 0", borderColor: "#444" }} />
|
||||
|
||||
<div style={{ fontSize: "8px", color: "#aaa" }}>
|
||||
Connect <strong>Instruction Nodes</strong> below to define roles.
|
||||
Each instruction node will send back its results (like screenshots) and act as a separate data output.
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "8px", color: "#aaa", marginTop: "4px" }}>
|
||||
<strong>Supported Roles:</strong>
|
||||
<ul style={{ paddingLeft: "14px", marginTop: "2px", marginBottom: "0" }}>
|
||||
<li><code>screenshot</code>: Capture a region with interval and overlay</li>
|
||||
{/* Future roles will be listed here */}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "Borealis_Agent",
|
||||
label: "Borealis Agent",
|
||||
description: `
|
||||
Main Agent Node
|
||||
|
||||
- Selects an available agent
|
||||
- Connect role nodes below to assign tasks to the agent
|
||||
- Roles include screenshots, keyboard macros, etc.
|
||||
`.trim(),
|
||||
content: "Select and provision a Borealis Agent with task roles",
|
||||
component: BorealisAgentNode
|
||||
};
|
@ -0,0 +1,186 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Agent/Node_Agent_Role_Screenshot.jsx
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
||||
import ShareIcon from "@mui/icons-material/Share";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
|
||||
if (!window.BorealisValueBus) {
|
||||
window.BorealisValueBus = {};
|
||||
}
|
||||
|
||||
if (!window.BorealisUpdateRate) {
|
||||
window.BorealisUpdateRate = 100;
|
||||
}
|
||||
|
||||
const ScreenshotInstructionNode = ({ id, data }) => {
|
||||
const { setNodes, getNodes } = useReactFlow();
|
||||
const edges = useStore(state => state.edges);
|
||||
|
||||
const [interval, setInterval] = useState(data?.interval || 1000);
|
||||
const [region, setRegion] = useState({
|
||||
x: data?.x ?? 250,
|
||||
y: data?.y ?? 100,
|
||||
w: data?.w ?? 300,
|
||||
h: data?.h ?? 200,
|
||||
});
|
||||
const [visible, setVisible] = useState(data?.visible ?? true);
|
||||
const [alias, setAlias] = useState(data?.alias || "");
|
||||
const [imageBase64, setImageBase64] = useState("");
|
||||
|
||||
const base64Ref = useRef("");
|
||||
|
||||
const handleCopyLiveViewLink = () => {
|
||||
const agentEdge = edges.find(e => e.target === id && e.sourceHandle === "provisioner");
|
||||
if (!agentEdge) {
|
||||
alert("No upstream agent connection found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const agentNode = getNodes().find(n => n.id === agentEdge.source);
|
||||
const selectedAgentId = agentNode?.data?.agent_id;
|
||||
|
||||
if (!selectedAgentId) {
|
||||
alert("Upstream agent node does not have a selected agent.");
|
||||
return;
|
||||
}
|
||||
|
||||
const liveUrl = `${window.location.origin}/api/agent/${selectedAgentId}/node/${id}/screenshot/live`;
|
||||
navigator.clipboard.writeText(liveUrl)
|
||||
.then(() => console.log(`[Clipboard] Copied Live View URL: ${liveUrl}`))
|
||||
.catch(err => console.error("Clipboard copy failed:", err));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
const val = base64Ref.current;
|
||||
|
||||
console.log(`[Screenshot Node] setInterval update. Current base64 length: ${val?.length || 0}`);
|
||||
|
||||
if (!val) return;
|
||||
|
||||
window.BorealisValueBus[id] = val;
|
||||
setNodes(nds =>
|
||||
nds.map(n =>
|
||||
n.id === id
|
||||
? { ...n, data: { ...n.data, value: val } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}, window.BorealisUpdateRate || 100);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [id, setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = window.BorealisSocket || null;
|
||||
if (!socket) {
|
||||
console.warn("[Screenshot Node] BorealisSocket not available");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Screenshot Node] Listening for agent_screenshot_task with node_id: ${id}`);
|
||||
|
||||
const handleScreenshot = (payload) => {
|
||||
console.log("[Screenshot Node] Received payload:", payload);
|
||||
|
||||
if (payload?.node_id === id && payload?.image_base64) {
|
||||
base64Ref.current = payload.image_base64;
|
||||
setImageBase64(payload.image_base64);
|
||||
window.BorealisValueBus[id] = payload.image_base64;
|
||||
|
||||
console.log(`[Screenshot Node] Updated base64Ref and ValueBus for ${id}, length: ${payload.image_base64.length}`);
|
||||
} else {
|
||||
console.log(`[Screenshot Node] Ignored payload for mismatched node_id (${payload?.node_id})`);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("agent_screenshot_task", handleScreenshot);
|
||||
return () => socket.off("agent_screenshot_task", handleScreenshot);
|
||||
}, [id]);
|
||||
|
||||
window.__BorealisInstructionNodes = window.__BorealisInstructionNodes || {};
|
||||
window.__BorealisInstructionNodes[id] = () => ({
|
||||
node_id: id,
|
||||
role: "screenshot",
|
||||
interval,
|
||||
visible,
|
||||
alias,
|
||||
...region
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="borealis-node" style={{ position: "relative" }}>
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
|
||||
<div className="borealis-node-header">Agent Role: Screenshot</div>
|
||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
||||
<label>Update Interval (ms):</label>
|
||||
<input
|
||||
type="number"
|
||||
min="100"
|
||||
step="100"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(Number(e.target.value))}
|
||||
style={{ width: "100%", marginBottom: "4px" }}
|
||||
/>
|
||||
|
||||
<label>Region X / Y / W / H:</label>
|
||||
<div style={{ display: "flex", gap: "4px", marginBottom: "4px" }}>
|
||||
<input type="number" value={region.x} onChange={(e) => setRegion({ ...region, x: Number(e.target.value) })} style={{ width: "25%" }} />
|
||||
<input type="number" value={region.y} onChange={(e) => setRegion({ ...region, y: Number(e.target.value) })} style={{ width: "25%" }} />
|
||||
<input type="number" value={region.w} onChange={(e) => setRegion({ ...region, w: Number(e.target.value) })} style={{ width: "25%" }} />
|
||||
<input type="number" value={region.h} onChange={(e) => setRegion({ ...region, h: Number(e.target.value) })} style={{ width: "25%" }} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "4px" }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible}
|
||||
onChange={() => setVisible(!visible)}
|
||||
style={{ marginRight: "4px" }}
|
||||
/>
|
||||
Show Overlay on Agent
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>Overlay Label:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
placeholder="Label (optional)"
|
||||
style={{ width: "100%", fontSize: "9px", marginBottom: "6px" }}
|
||||
/>
|
||||
|
||||
<div style={{ textAlign: "center", fontSize: "8px", color: "#aaa" }}>
|
||||
{imageBase64
|
||||
? `Last image: ${Math.round(imageBase64.length / 1024)} KB`
|
||||
: "Awaiting Screenshot Data..."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "absolute", top: 4, right: 4 }}>
|
||||
<IconButton size="small" onClick={handleCopyLiveViewLink}>
|
||||
<ShareIcon style={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "Agent_Role_Screenshot",
|
||||
label: "Agent Role: Screenshot",
|
||||
description: `
|
||||
Agent Role Node: Screenshot Region
|
||||
|
||||
- Defines a single region capture role
|
||||
- Allows custom update interval and overlay
|
||||
- Emits captured base64 PNG data from agent
|
||||
`.trim(),
|
||||
content: "Capture screenshot region via agent",
|
||||
component: ScreenshotInstructionNode
|
||||
};
|
325
Data/Server/WebUI/src/nodes/Alerting/Node_Alert_Sound.jsx
Normal file
325
Data/Server/WebUI/src/nodes/Alerting/Node_Alert_Sound.jsx
Normal file
@ -0,0 +1,325 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: Node_Alert_Sound.jsx
|
||||
|
||||
/**
|
||||
* ==================================================
|
||||
* Borealis - Alert Sound Node (with Base64 Restore)
|
||||
* ==================================================
|
||||
*
|
||||
* COMPONENT ROLE:
|
||||
* Plays a sound when input = "1". Provides a visual indicator:
|
||||
* - Green dot: input is 0
|
||||
* - Red dot: input is 1
|
||||
*
|
||||
* Modes:
|
||||
* - "Once": Triggers once when going 0 -> 1
|
||||
* - "Constant": Triggers repeatedly every X ms while input = 1
|
||||
*
|
||||
* Supports embedding base64 audio directly into the workflow.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
||||
|
||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
||||
|
||||
const AlertSoundNode = ({ id, data }) => {
|
||||
const edges = useStore(state => state.edges);
|
||||
const { setNodes } = useReactFlow();
|
||||
|
||||
const [alertType, setAlertType] = useState(data?.alertType || "Once");
|
||||
const [intervalMs, setIntervalMs] = useState(data?.interval || 1000);
|
||||
const [prevInput, setPrevInput] = useState("0");
|
||||
const [customAudioBase64, setCustomAudioBase64] = useState(data?.audio || null);
|
||||
const [currentInput, setCurrentInput] = useState("0");
|
||||
|
||||
const audioRef = useRef(null);
|
||||
|
||||
const playSound = () => {
|
||||
if (audioRef.current) {
|
||||
console.log(`[Alert Node ${id}] Attempting to play sound`);
|
||||
try {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
audioRef.current.load();
|
||||
audioRef.current.play().then(() => {
|
||||
console.log(`[Alert Node ${id}] Sound played successfully`);
|
||||
}).catch((err) => {
|
||||
console.warn(`[Alert Node ${id}] Audio play blocked or errored:`, err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[Alert Node ${id}] Failed to play sound:`, err);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[Alert Node ${id}] No audioRef loaded`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
console.log(`[Alert Node ${id}] File selected:`, file.name, file.type);
|
||||
|
||||
const supportedTypes = ["audio/wav", "audio/mp3", "audio/mpeg", "audio/ogg"];
|
||||
if (!supportedTypes.includes(file.type)) {
|
||||
console.warn(`[Alert Node ${id}] Unsupported audio type: ${file.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const base64 = e.target.result;
|
||||
const mimeType = file.type || "audio/mpeg";
|
||||
const safeURL = base64.startsWith("data:")
|
||||
? base64
|
||||
: `data:${mimeType};base64,${base64}`;
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = "";
|
||||
audioRef.current.load();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
const newAudio = new Audio();
|
||||
newAudio.src = safeURL;
|
||||
|
||||
let readyFired = false;
|
||||
|
||||
newAudio.addEventListener("canplaythrough", () => {
|
||||
if (readyFired) return;
|
||||
readyFired = true;
|
||||
console.log(`[Alert Node ${id}] Audio is decodable and ready: ${file.name}`);
|
||||
|
||||
setCustomAudioBase64(safeURL);
|
||||
audioRef.current = newAudio;
|
||||
newAudio.load();
|
||||
|
||||
setNodes(nds =>
|
||||
nds.map(n =>
|
||||
n.id === id
|
||||
? { ...n, data: { ...n.data, audio: safeURL } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!readyFired) {
|
||||
console.warn(`[Alert Node ${id}] WARNING: Audio not marked ready in time. May fail silently.`);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
reader.onerror = (e) => {
|
||||
console.error(`[Alert Node ${id}] File read error:`, e);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// Restore embedded audio from saved workflow
|
||||
useEffect(() => {
|
||||
if (customAudioBase64) {
|
||||
console.log(`[Alert Node ${id}] Loading embedded audio from workflow`);
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = "";
|
||||
audioRef.current.load();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
const loadedAudio = new Audio(customAudioBase64);
|
||||
loadedAudio.addEventListener("canplaythrough", () => {
|
||||
console.log(`[Alert Node ${id}] Embedded audio ready`);
|
||||
});
|
||||
|
||||
audioRef.current = loadedAudio;
|
||||
loadedAudio.load();
|
||||
} else {
|
||||
console.log(`[Alert Node ${id}] No custom audio, using fallback silent wav`);
|
||||
audioRef.current = new Audio("data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YRAAAAAA");
|
||||
audioRef.current.load();
|
||||
}
|
||||
}, [customAudioBase64]);
|
||||
|
||||
useEffect(() => {
|
||||
let currentRate = window.BorealisUpdateRate;
|
||||
let intervalId = null;
|
||||
|
||||
const runLogic = () => {
|
||||
const inputEdge = edges.find(e => e.target === id);
|
||||
const sourceId = inputEdge?.source || null;
|
||||
const val = sourceId ? (window.BorealisValueBus[sourceId] || "0") : "0";
|
||||
|
||||
setCurrentInput(val);
|
||||
|
||||
if (alertType === "Once") {
|
||||
if (val === "1" && prevInput !== "1") {
|
||||
console.log(`[Alert Node ${id}] Triggered ONCE playback`);
|
||||
playSound();
|
||||
}
|
||||
}
|
||||
|
||||
setPrevInput(val);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (alertType === "Constant") {
|
||||
intervalId = setInterval(() => {
|
||||
const inputEdge = edges.find(e => e.target === id);
|
||||
const sourceId = inputEdge?.source || null;
|
||||
const val = sourceId ? (window.BorealisValueBus[sourceId] || "0") : "0";
|
||||
setCurrentInput(val);
|
||||
if (String(val) === "1") {
|
||||
console.log(`[Alert Node ${id}] Triggered CONSTANT playback`);
|
||||
playSound();
|
||||
}
|
||||
}, intervalMs);
|
||||
} else {
|
||||
intervalId = setInterval(runLogic, currentRate);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
|
||||
const monitor = setInterval(() => {
|
||||
const newRate = window.BorealisUpdateRate;
|
||||
if (newRate !== currentRate && alertType === "Once") {
|
||||
currentRate = newRate;
|
||||
clearInterval(intervalId);
|
||||
start();
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
clearInterval(monitor);
|
||||
};
|
||||
}, [edges, alertType, intervalMs, prevInput]);
|
||||
|
||||
const indicatorColor = currentInput === "1" ? "#ff4444" : "#44ff44";
|
||||
|
||||
return (
|
||||
<div className="borealis-node" style={{ position: "relative" }}>
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
|
||||
{/* Header with indicator dot */}
|
||||
<div className="borealis-node-header" style={{ position: "relative" }}>
|
||||
{data?.label || "Alert Sound"}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
top: "12px", // Adjusted from 6px to 12px for better centering
|
||||
right: "6px",
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: indicatorColor,
|
||||
border: "1px solid #222"
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="borealis-node-content">
|
||||
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
|
||||
Play a sound when input equals "1"
|
||||
</div>
|
||||
|
||||
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
|
||||
Alerting Type:
|
||||
</label>
|
||||
<select
|
||||
value={alertType}
|
||||
onChange={(e) => setAlertType(e.target.value)}
|
||||
style={dropdownStyle}
|
||||
>
|
||||
<option value="Once">Once</option>
|
||||
<option value="Constant">Constant</option>
|
||||
</select>
|
||||
|
||||
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
|
||||
Alert Interval (ms):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="100"
|
||||
step="100"
|
||||
value={intervalMs}
|
||||
onChange={(e) => setIntervalMs(parseInt(e.target.value))}
|
||||
disabled={alertType === "Once"}
|
||||
style={{
|
||||
...inputStyle,
|
||||
background: alertType === "Once" ? "#2a2a2a" : "#1e1e1e"
|
||||
}}
|
||||
/>
|
||||
|
||||
<label style={{ fontSize: "9px", display: "block", marginTop: "6px", marginBottom: "4px" }}>
|
||||
Custom Sound:
|
||||
</label>
|
||||
|
||||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".wav,.mp3,.mpeg,.ogg"
|
||||
onChange={handleFileUpload}
|
||||
style={{ ...inputStyle, marginBottom: 0, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
fontSize: "9px",
|
||||
padding: "4px 8px",
|
||||
backgroundColor: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
onClick={playSound}
|
||||
title="Test playback"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const dropdownStyle = {
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
width: "100%",
|
||||
marginBottom: "8px"
|
||||
};
|
||||
|
||||
const inputStyle = {
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
width: "100%",
|
||||
marginBottom: "8px"
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "AlertSoundNode",
|
||||
label: "Alert Sound",
|
||||
description: `
|
||||
Plays a sound alert when input = "1"
|
||||
|
||||
- "Once" = Only when 0 -> 1 transition
|
||||
- "Constant" = Repeats every X ms while input stays 1
|
||||
- Custom audio supported (MP3/WAV/OGG)
|
||||
- Base64 audio embedded in workflow and restored
|
||||
- Visual status indicator (green = 0, red = 1)
|
||||
- Manual "Test" button for validation
|
||||
`.trim(),
|
||||
content: "Sound alert when input value = 1",
|
||||
component: AlertSoundNode
|
||||
};
|
@ -0,0 +1,242 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: Node_OCR_Text_Extraction.jsx
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
||||
|
||||
// Base64 comparison using hash (lightweight)
|
||||
const getHashScore = (str = "") => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i += 101) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
};
|
||||
|
||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
||||
|
||||
const OCRNode = ({ id, data }) => {
|
||||
const edges = useStore((state) => state.edges);
|
||||
const { setNodes } = useReactFlow();
|
||||
|
||||
const [ocrOutput, setOcrOutput] = useState("");
|
||||
const [engine, setEngine] = useState("None");
|
||||
const [backend, setBackend] = useState("CPU");
|
||||
const [dataType, setDataType] = useState("Mixed");
|
||||
|
||||
const [customRateEnabled, setCustomRateEnabled] = useState(true);
|
||||
const [customRateMs, setCustomRateMs] = useState(1000);
|
||||
|
||||
const [changeThreshold, setChangeThreshold] = useState(0);
|
||||
|
||||
const valueRef = useRef("");
|
||||
const lastUsed = useRef({ engine: "", backend: "", dataType: "" });
|
||||
const lastProcessedAt = useRef(0);
|
||||
const lastImageHash = useRef(0);
|
||||
|
||||
const sendToOCRAPI = async (base64) => {
|
||||
const cleanBase64 = base64.replace(/^data:image\/[a-zA-Z]+;base64,/, "");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ocr", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_base64: cleanBase64, engine, backend })
|
||||
});
|
||||
const result = await response.json();
|
||||
return response.ok && Array.isArray(result.lines)
|
||||
? result.lines
|
||||
: [`[ERROR] ${result.error || "Invalid OCR response."}`];
|
||||
} catch (err) {
|
||||
return [`[ERROR] OCR API request failed: ${err.message}`];
|
||||
}
|
||||
};
|
||||
|
||||
const filterLines = (lines) => {
|
||||
if (dataType === "Numerical") {
|
||||
return lines.map(line => line.replace(/[^\d.%\s]/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
||||
}
|
||||
if (dataType === "String") {
|
||||
return lines.map(line => line.replace(/[^a-zA-Z\s]/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId = null;
|
||||
let currentRate = window.BorealisUpdateRate || 100;
|
||||
|
||||
const runNodeLogic = async () => {
|
||||
const inputEdge = edges.find((e) => e.target === id);
|
||||
if (!inputEdge) {
|
||||
window.BorealisValueBus[id] = [];
|
||||
setOcrOutput("");
|
||||
return;
|
||||
}
|
||||
|
||||
const upstreamValue = window.BorealisValueBus[inputEdge.source] || "";
|
||||
const now = Date.now();
|
||||
|
||||
const effectiveRate = customRateEnabled ? customRateMs : window.BorealisUpdateRate || 100;
|
||||
const configChanged =
|
||||
lastUsed.current.engine !== engine ||
|
||||
lastUsed.current.backend !== backend ||
|
||||
lastUsed.current.dataType !== dataType;
|
||||
|
||||
const upstreamHash = getHashScore(upstreamValue);
|
||||
const hashDelta = Math.abs(upstreamHash - lastImageHash.current);
|
||||
const hashThreshold = (changeThreshold / 100) * 1000000000;
|
||||
|
||||
const imageChanged = hashDelta > hashThreshold;
|
||||
|
||||
// Only reprocess if config changed, or image changed AND time passed
|
||||
if (!configChanged && (!imageChanged || (now - lastProcessedAt.current < effectiveRate))) return;
|
||||
|
||||
lastUsed.current = { engine, backend, dataType };
|
||||
lastProcessedAt.current = now;
|
||||
lastImageHash.current = upstreamHash;
|
||||
valueRef.current = upstreamValue;
|
||||
|
||||
const lines = await sendToOCRAPI(upstreamValue);
|
||||
const filtered = filterLines(lines);
|
||||
setOcrOutput(filtered.join("\n"));
|
||||
window.BorealisValueBus[id] = filtered;
|
||||
};
|
||||
|
||||
intervalId = setInterval(runNodeLogic, currentRate);
|
||||
|
||||
const monitor = setInterval(() => {
|
||||
const newRate = window.BorealisUpdateRate || 100;
|
||||
if (newRate !== currentRate) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = setInterval(runNodeLogic, newRate);
|
||||
currentRate = newRate;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
clearInterval(monitor);
|
||||
};
|
||||
}, [id, engine, backend, dataType, customRateEnabled, customRateMs, changeThreshold, edges]);
|
||||
|
||||
return (
|
||||
<div className="borealis-node" style={{ minWidth: "200px" }}>
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
<div className="borealis-node-header">OCR-Based Text Extraction</div>
|
||||
<div className="borealis-node-content">
|
||||
<div style={{ fontSize: "9px", marginBottom: "8px", color: "#ccc" }}>
|
||||
Extract Multi-Line Text from Upstream Image Node
|
||||
</div>
|
||||
|
||||
<label style={labelStyle}>OCR Engine:</label>
|
||||
<select value={engine} onChange={(e) => setEngine(e.target.value)} style={dropdownStyle}>
|
||||
<option value="None">None</option>
|
||||
<option value="TesseractOCR">TesseractOCR</option>
|
||||
<option value="EasyOCR">EasyOCR</option>
|
||||
</select>
|
||||
|
||||
<label style={labelStyle}>Compute:</label>
|
||||
<select value={backend} onChange={(e) => setBackend(e.target.value)} style={dropdownStyle} disabled={engine === "None"}>
|
||||
<option value="CPU">CPU</option>
|
||||
<option value="GPU">GPU</option>
|
||||
</select>
|
||||
|
||||
<label style={labelStyle}>Data Type:</label>
|
||||
<select value={dataType} onChange={(e) => setDataType(e.target.value)} style={dropdownStyle}>
|
||||
<option value="Mixed">Mixed Data</option>
|
||||
<option value="Numerical">Numerical Data</option>
|
||||
<option value="String">String Data</option>
|
||||
</select>
|
||||
|
||||
<label style={labelStyle}>Custom API Rate-Limit (ms):</label>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={customRateEnabled}
|
||||
onChange={(e) => setCustomRateEnabled(e.target.checked)}
|
||||
style={{ marginRight: "8px" }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="100"
|
||||
step="100"
|
||||
value={customRateMs}
|
||||
onChange={(e) => setCustomRateMs(Number(e.target.value))}
|
||||
disabled={!customRateEnabled}
|
||||
style={numberInputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style={labelStyle}>Change Detection Sensitivity Threshold:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value={changeThreshold}
|
||||
onChange={(e) => setChangeThreshold(Number(e.target.value))}
|
||||
style={numberInputStyle}
|
||||
/>
|
||||
|
||||
<label style={labelStyle}>OCR Output:</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={ocrOutput}
|
||||
rows={6}
|
||||
style={{
|
||||
width: "100%",
|
||||
fontSize: "9px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
padding: "4px",
|
||||
resize: "vertical"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const labelStyle = {
|
||||
fontSize: "9px",
|
||||
display: "block",
|
||||
marginTop: "6px",
|
||||
marginBottom: "2px"
|
||||
};
|
||||
|
||||
const dropdownStyle = {
|
||||
width: "100%",
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px"
|
||||
};
|
||||
|
||||
const numberInputStyle = {
|
||||
width: "80px",
|
||||
fontSize: "9px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
padding: "2px 4px",
|
||||
marginBottom: "8px"
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "OCR_Text_Extraction",
|
||||
label: "OCR Text Extraction",
|
||||
description: `
|
||||
Extract text from upstream image using backend OCR engine via API.
|
||||
Includes rate limiting and sensitivity detection for smart processing.`,
|
||||
content: "Extract Multi-Line Text from Upstream Image Node",
|
||||
component: OCRNode
|
||||
};
|
@ -0,0 +1,143 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: Node_Array_Index_Extractor.jsx
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
||||
|
||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
||||
|
||||
const ArrayIndexExtractorNode = ({ id, data }) => {
|
||||
const edges = useStore((state) => state.edges);
|
||||
const { setNodes } = useReactFlow();
|
||||
|
||||
const [lineNumber, setLineNumber] = useState(data?.lineNumber || 1);
|
||||
const [result, setResult] = useState("Line Does Not Exist");
|
||||
|
||||
const valueRef = useRef(result);
|
||||
|
||||
const handleLineNumberChange = (e) => {
|
||||
const num = parseInt(e.target.value, 10);
|
||||
const clamped = isNaN(num) ? 1 : Math.max(1, num);
|
||||
setLineNumber(clamped);
|
||||
|
||||
setNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, lineNumber: clamped } } : n
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId = null;
|
||||
let currentRate = window.BorealisUpdateRate;
|
||||
|
||||
const runNodeLogic = () => {
|
||||
const inputEdge = edges.find((e) => e.target === id);
|
||||
if (!inputEdge) {
|
||||
valueRef.current = "Line Does Not Exist";
|
||||
setResult("Line Does Not Exist");
|
||||
window.BorealisValueBus[id] = "Line Does Not Exist";
|
||||
return;
|
||||
}
|
||||
|
||||
const upstreamValue = window.BorealisValueBus[inputEdge.source];
|
||||
if (!Array.isArray(upstreamValue)) {
|
||||
valueRef.current = "Line Does Not Exist";
|
||||
setResult("Line Does Not Exist");
|
||||
window.BorealisValueBus[id] = "Line Does Not Exist";
|
||||
return;
|
||||
}
|
||||
|
||||
const index = Math.max(0, lineNumber - 1); // Convert 1-based input to 0-based
|
||||
const selected = upstreamValue[index] ?? "Line Does Not Exist";
|
||||
|
||||
if (selected !== valueRef.current) {
|
||||
valueRef.current = selected;
|
||||
setResult(selected);
|
||||
window.BorealisValueBus[id] = selected;
|
||||
}
|
||||
};
|
||||
|
||||
intervalId = setInterval(runNodeLogic, currentRate);
|
||||
|
||||
const monitor = setInterval(() => {
|
||||
const newRate = window.BorealisUpdateRate;
|
||||
if (newRate !== currentRate) {
|
||||
clearInterval(intervalId);
|
||||
currentRate = newRate;
|
||||
intervalId = setInterval(runNodeLogic, currentRate);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
clearInterval(monitor);
|
||||
};
|
||||
}, [id, edges, lineNumber]);
|
||||
|
||||
return (
|
||||
<div className="borealis-node">
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
<div className="borealis-node-header">Array Index Extractor</div>
|
||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
||||
<div style={{ marginBottom: "6px", color: "#ccc" }}>
|
||||
Output a Specific Array Index's Value
|
||||
</div>
|
||||
|
||||
<label style={{ display: "block", marginBottom: "2px" }}>
|
||||
Line Number (1 = First Line):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={lineNumber}
|
||||
onChange={handleLineNumberChange}
|
||||
style={{
|
||||
width: "100%",
|
||||
fontSize: "9px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
padding: "3px",
|
||||
marginBottom: "6px"
|
||||
}}
|
||||
/>
|
||||
|
||||
<label style={{ display: "block", marginBottom: "2px" }}>
|
||||
Output:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={result}
|
||||
disabled
|
||||
style={{
|
||||
width: "100%",
|
||||
fontSize: "9px",
|
||||
background: "#2a2a2a",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
padding: "3px"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "ArrayIndexExtractor",
|
||||
label: "Array Index Extractor",
|
||||
description: `
|
||||
Outputs a specific line from an upstream array (e.g., OCR multi-line output).
|
||||
|
||||
- User specifies the line number (1-based index)
|
||||
- Outputs the value from that line if it exists
|
||||
- If the index is out of bounds, outputs "Line Does Not Exist"
|
||||
`.trim(),
|
||||
content: "Output a Specific Array Index's Value",
|
||||
component: ArrayIndexExtractorNode
|
||||
};
|
192
Data/Server/WebUI/src/nodes/General Purpose/Node_Data.jsx
Normal file
192
Data/Server/WebUI/src/nodes/General Purpose/Node_Data.jsx
Normal file
@ -0,0 +1,192 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Data.jsx
|
||||
|
||||
/**
|
||||
* ============================================
|
||||
* Borealis - Standard Live Data Node Template
|
||||
* ============================================
|
||||
*
|
||||
* COMPONENT ROLE:
|
||||
* This component defines a "data conduit" node that can accept input,
|
||||
* process/override it with local logic, and forward the output on a timed basis.
|
||||
*
|
||||
* It serves as the core behavior model for other nodes that rely on live propagation.
|
||||
* Clone and extend this file to create nodes with specialized logic.
|
||||
*
|
||||
* CORE CONCEPTS:
|
||||
* - Uses a centralized shared memory (window.BorealisValueBus) for value sharing
|
||||
* - Synchronizes with upstream nodes based on ReactFlow edges
|
||||
* - Emits to downstream nodes by updating its own BorealisValueBus[id] value
|
||||
* - Controlled by a global polling timer (window.BorealisUpdateRate)
|
||||
*
|
||||
* LIFECYCLE SUMMARY:
|
||||
* - onMount: initializes logic loop and sync monitor
|
||||
* - onUpdate: watches edges and global rate, reconfigures as needed
|
||||
* - onUnmount: cleans up all timers
|
||||
*
|
||||
* DATA FLOW OVERVIEW:
|
||||
* - INPUT: if a left-edge (target) is connected, disables manual editing
|
||||
* - OUTPUT: propagates renderValue to downstream nodes via right-edge (source)
|
||||
*
|
||||
* STRUCTURE:
|
||||
* - Node UI includes:
|
||||
* * Label (from data.label)
|
||||
* * Body description (from data.content)
|
||||
* * Input textbox (disabled if input is connected)
|
||||
*
|
||||
* HOW TO EXTEND:
|
||||
* - For transformations, insert logic into runNodeLogic()
|
||||
* - To validate or restrict input types, modify handleManualInput()
|
||||
* - For side-effects or external API calls, add hooks inside runNodeLogic()
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
||||
|
||||
// Global Shared Bus for Node Data Propagation
|
||||
if (!window.BorealisValueBus) {
|
||||
window.BorealisValueBus = {};
|
||||
}
|
||||
|
||||
// Global Update Rate (ms) for All Data Nodes
|
||||
if (!window.BorealisUpdateRate) {
|
||||
window.BorealisUpdateRate = 100;
|
||||
}
|
||||
|
||||
const DataNode = ({ id, data }) => {
|
||||
const { setNodes } = useReactFlow();
|
||||
const edges = useStore(state => state.edges);
|
||||
|
||||
const [renderValue, setRenderValue] = useState(data?.value || "");
|
||||
const valueRef = useRef(renderValue);
|
||||
|
||||
// Manual input handler (disabled if connected to input)
|
||||
const handleManualInput = (e) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
// TODO: Add input validation/sanitization here if needed
|
||||
valueRef.current = newValue;
|
||||
setRenderValue(newValue);
|
||||
|
||||
window.BorealisValueBus[id] = newValue;
|
||||
|
||||
setNodes(nds =>
|
||||
nds.map(n =>
|
||||
n.id === id
|
||||
? { ...n, data: { ...n.data, value: newValue } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let currentRate = window.BorealisUpdateRate || 100;
|
||||
let intervalId = null;
|
||||
|
||||
const runNodeLogic = () => {
|
||||
const inputEdge = edges.find(e => e?.target === id);
|
||||
const hasInput = Boolean(inputEdge);
|
||||
|
||||
if (hasInput && inputEdge.source) {
|
||||
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
|
||||
|
||||
// TODO: Insert custom transform logic here (e.g., parseInt, apply formula)
|
||||
|
||||
if (upstreamValue !== valueRef.current) {
|
||||
valueRef.current = upstreamValue;
|
||||
setRenderValue(upstreamValue);
|
||||
window.BorealisValueBus[id] = upstreamValue;
|
||||
|
||||
setNodes(nds =>
|
||||
nds.map(n =>
|
||||
n.id === id
|
||||
? { ...n, data: { ...n.data, value: upstreamValue } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// OUTPUT BROADCAST: emits to downstream via shared memory
|
||||
window.BorealisValueBus[id] = valueRef.current;
|
||||
}
|
||||
};
|
||||
|
||||
const startInterval = () => {
|
||||
intervalId = setInterval(runNodeLogic, currentRate);
|
||||
};
|
||||
|
||||
startInterval();
|
||||
|
||||
// Monitor for global update rate changes
|
||||
const monitor = setInterval(() => {
|
||||
const newRate = window.BorealisUpdateRate || 100;
|
||||
if (newRate !== currentRate) {
|
||||
currentRate = newRate;
|
||||
clearInterval(intervalId);
|
||||
startInterval();
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
clearInterval(monitor);
|
||||
};
|
||||
}, [id, setNodes, edges]);
|
||||
|
||||
const inputEdge = edges.find(e => e?.target === id);
|
||||
const hasInput = Boolean(inputEdge);
|
||||
const upstreamId = inputEdge?.source || "";
|
||||
const upstreamValue = window.BorealisValueBus[upstreamId] || "";
|
||||
|
||||
return (
|
||||
<div className="borealis-node">
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
|
||||
<div className="borealis-node-header">
|
||||
{data?.label || "Data Node"}
|
||||
</div>
|
||||
|
||||
<div className="borealis-node-content">
|
||||
{/* Description visible in node body */}
|
||||
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
|
||||
{data?.content || "Foundational node for live value propagation."}
|
||||
</div>
|
||||
|
||||
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
|
||||
Value:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={renderValue}
|
||||
onChange={handleManualInput}
|
||||
disabled={hasInput}
|
||||
style={{
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: hasInput ? "#2a2a2a" : "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
width: "100%"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "DataNode", // REQUIRED: unique identifier for the node type
|
||||
label: "String / Number",
|
||||
description: `
|
||||
Foundational Data Node
|
||||
|
||||
- Accepts input from another node
|
||||
- If no input is connected, allows user-defined value
|
||||
- Pushes value to downstream nodes every X ms
|
||||
- Uses BorealisValueBus to communicate with other nodes
|
||||
`.trim(),
|
||||
content: "Store a String or Number",
|
||||
component: DataNode
|
||||
};
|
@ -0,0 +1,175 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Logical_Operators.jsx
|
||||
|
||||
/**
|
||||
* ==============================================
|
||||
* Borealis - Comparison Node (Logic Evaluation)
|
||||
* ==============================================
|
||||
*
|
||||
* COMPONENT ROLE:
|
||||
* This node takes two input values and evaluates them using a selected comparison operator.
|
||||
* It returns 1 (true) or 0 (false) depending on the result of the comparison.
|
||||
*
|
||||
* FEATURES:
|
||||
* - Dropdown to select input type: "Number" or "String"
|
||||
* - Dropdown to select comparison operator: ==, !=, >, <, >=, <=
|
||||
* - Dynamically disables numeric-only operators for string inputs
|
||||
* - Automatically resets operator to == when switching to String
|
||||
* - Supports summing multiple inputs per side (A, B)
|
||||
* - For "String" mode: concatenates inputs in connection order
|
||||
* - Uses BorealisValueBus for input/output
|
||||
* - Controlled by global update timer
|
||||
*
|
||||
* STRUCTURE:
|
||||
* - Label and Description
|
||||
* - Input A (top-left) and Input B (middle-left)
|
||||
* - Output (right edge) result: 1 (true) or 0 (false)
|
||||
* - Operator dropdown and Input Type dropdown
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
||||
|
||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
||||
|
||||
const ComparisonNode = ({ id, data }) => {
|
||||
const { setNodes } = useReactFlow();
|
||||
const edges = useStore(state => state.edges);
|
||||
|
||||
const [inputType, setInputType] = useState(data?.inputType || "Number");
|
||||
const [operator, setOperator] = useState(data?.operator || "Equal (==)");
|
||||
const [renderValue, setRenderValue] = useState("0");
|
||||
const valueRef = useRef("0");
|
||||
|
||||
useEffect(() => {
|
||||
if (inputType === "String" && !["Equal (==)", "Not Equal (!=)"].includes(operator)) {
|
||||
setOperator("Equal (==)");
|
||||
}
|
||||
}, [inputType]);
|
||||
|
||||
useEffect(() => {
|
||||
let currentRate = window.BorealisUpdateRate;
|
||||
let intervalId = null;
|
||||
|
||||
const runNodeLogic = () => {
|
||||
const edgeInputsA = edges.filter(e => e?.target === id && e.targetHandle === "a");
|
||||
const edgeInputsB = edges.filter(e => e?.target === id && e.targetHandle === "b");
|
||||
|
||||
const extractValues = (edgeList) => {
|
||||
const values = edgeList.map(e => window.BorealisValueBus[e.source]).filter(v => v !== undefined);
|
||||
if (inputType === "Number") {
|
||||
return values.reduce((sum, v) => sum + (parseFloat(v) || 0), 0);
|
||||
}
|
||||
return values.join("");
|
||||
};
|
||||
|
||||
const a = extractValues(edgeInputsA);
|
||||
const b = extractValues(edgeInputsB);
|
||||
|
||||
const resultMap = {
|
||||
"Equal (==)": a === b,
|
||||
"Not Equal (!=)": a !== b,
|
||||
"Greater Than (>)": a > b,
|
||||
"Less Than (<)": a < b,
|
||||
"Greater Than or Equal (>=)": a >= b,
|
||||
"Less Than or Equal (<=)": a <= b
|
||||
};
|
||||
|
||||
const result = resultMap[operator] ? "1" : "0";
|
||||
|
||||
valueRef.current = result;
|
||||
setRenderValue(result);
|
||||
window.BorealisValueBus[id] = result;
|
||||
|
||||
setNodes(nds =>
|
||||
nds.map(n =>
|
||||
n.id === id ? { ...n, data: { ...n.data, value: result, inputType, operator } } : n
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
intervalId = setInterval(runNodeLogic, currentRate);
|
||||
|
||||
const monitor = setInterval(() => {
|
||||
const newRate = window.BorealisUpdateRate;
|
||||
if (newRate !== currentRate) {
|
||||
clearInterval(intervalId);
|
||||
currentRate = newRate;
|
||||
intervalId = setInterval(runNodeLogic, currentRate);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
clearInterval(monitor);
|
||||
};
|
||||
}, [id, edges, inputType, operator, setNodes]);
|
||||
|
||||
return (
|
||||
<div className="borealis-node">
|
||||
<div style={{ position: "absolute", left: -16, top: 12, fontSize: "8px", color: "#ccc" }}>A</div>
|
||||
<div style={{ position: "absolute", left: -16, top: 50, fontSize: "8px", color: "#ccc" }}>B</div>
|
||||
<Handle type="target" position={Position.Left} id="a" style={{ top: 12 }} className="borealis-handle" />
|
||||
<Handle type="target" position={Position.Left} id="b" style={{ top: 50 }} className="borealis-handle" />
|
||||
|
||||
<div className="borealis-node-header">
|
||||
{data?.label || "Comparison Node"}
|
||||
</div>
|
||||
|
||||
<div className="borealis-node-content">
|
||||
<div style={{ marginBottom: "6px", fontSize: "9px", color: "#ccc" }}>
|
||||
{data?.content || "Evaluates A vs B and outputs 1 (true) or 0 (false)."}
|
||||
</div>
|
||||
|
||||
<label style={{ fontSize: "9px" }}>Input Type:</label>
|
||||
<select value={inputType} onChange={(e) => setInputType(e.target.value)} style={dropdownStyle}>
|
||||
<option value="Number">Number</option>
|
||||
<option value="String">String</option>
|
||||
</select>
|
||||
|
||||
<label style={{ fontSize: "9px", marginTop: "6px" }}>Operator:</label>
|
||||
<select value={operator} onChange={(e) => setOperator(e.target.value)} style={dropdownStyle}>
|
||||
<option>Equal (==)</option>
|
||||
<option>Not Equal (!=)</option>
|
||||
<option disabled={inputType === "String"}>Greater Than (>)</option>
|
||||
<option disabled={inputType === "String"}>Less Than (<)</option>
|
||||
<option disabled={inputType === "String"}>Greater Than or Equal (>=)</option>
|
||||
<option disabled={inputType === "String"}>Less Than or Equal (<=)</option>
|
||||
</select>
|
||||
|
||||
<div style={{ marginTop: "8px", fontSize: "9px" }}>Result: {renderValue}</div>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const dropdownStyle = {
|
||||
width: "100%",
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
marginBottom: "4px"
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "ComparisonNode",
|
||||
label: "Logic Comparison",
|
||||
description: `
|
||||
Compare Two Inputs (A vs B)
|
||||
|
||||
- Uses configurable operator
|
||||
- Supports numeric and string comparison
|
||||
- Aggregates multiple inputs by summing (Number) or joining (String in connection order)
|
||||
- Only == and != are valid for String mode
|
||||
- Automatically resets operator when switching to String mode
|
||||
- Outputs 1 (true) or 0 (false) into BorealisValueBus
|
||||
- Live-updates based on global timer
|
||||
`.trim(),
|
||||
content: "Compare A and B using Logic",
|
||||
component: ComparisonNode
|
||||
};
|
@ -0,0 +1,179 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Math_Operations.jsx
|
||||
|
||||
/**
|
||||
* ============================================
|
||||
* Borealis - Math Operation Node (Multi-Input A/B)
|
||||
* ============================================
|
||||
*
|
||||
* COMPONENT ROLE:
|
||||
* Performs live math operations on *two grouped input sets* (A and B).
|
||||
*
|
||||
* FUNCTIONALITY:
|
||||
* - Inputs connected to Handle A are summed
|
||||
* - Inputs connected to Handle B are summed
|
||||
* - Math operation is applied as: A <operator> B
|
||||
* - Result pushed via BorealisValueBus[id]
|
||||
*
|
||||
* SUPPORTED OPERATORS:
|
||||
* - Add, Subtract, Multiply, Divide, Average
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Handle, Position, useReactFlow, useStore } from "reactflow";
|
||||
|
||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
||||
|
||||
const MathNode = ({ id, data }) => {
|
||||
const { setNodes } = useReactFlow();
|
||||
const edges = useStore(state => state.edges);
|
||||
|
||||
const [operator, setOperator] = useState(data?.operator || "Add");
|
||||
const [result, setResult] = useState("0");
|
||||
const resultRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId = null;
|
||||
let currentRate = window.BorealisUpdateRate;
|
||||
|
||||
const runLogic = () => {
|
||||
const inputsA = edges.filter(e => e.target === id && e.targetHandle === "a");
|
||||
const inputsB = edges.filter(e => e.target === id && e.targetHandle === "b");
|
||||
|
||||
const sum = (list) =>
|
||||
list.map(e => parseFloat(window.BorealisValueBus[e.source]) || 0).reduce((a, b) => a + b, 0);
|
||||
|
||||
const valA = sum(inputsA);
|
||||
const valB = sum(inputsB);
|
||||
|
||||
let value = 0;
|
||||
switch (operator) {
|
||||
case "Add":
|
||||
value = valA + valB;
|
||||
break;
|
||||
case "Subtract":
|
||||
value = valA - valB;
|
||||
break;
|
||||
case "Multiply":
|
||||
value = valA * valB;
|
||||
break;
|
||||
case "Divide":
|
||||
value = valB !== 0 ? valA / valB : 0;
|
||||
break;
|
||||
case "Average":
|
||||
const totalInputs = inputsA.length + inputsB.length;
|
||||
const totalSum = valA + valB;
|
||||
value = totalInputs > 0 ? totalSum / totalInputs : 0;
|
||||
break;
|
||||
}
|
||||
|
||||
resultRef.current = value;
|
||||
setResult(value.toString());
|
||||
window.BorealisValueBus[id] = value.toString();
|
||||
|
||||
setNodes(nds =>
|
||||
nds.map(n =>
|
||||
n.id === id ? { ...n, data: { ...n.data, operator, value: value.toString() } } : n
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
intervalId = setInterval(runLogic, currentRate);
|
||||
|
||||
const monitor = setInterval(() => {
|
||||
const newRate = window.BorealisUpdateRate;
|
||||
if (newRate !== currentRate) {
|
||||
clearInterval(intervalId);
|
||||
currentRate = newRate;
|
||||
intervalId = setInterval(runLogic, currentRate);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
clearInterval(monitor);
|
||||
};
|
||||
}, [id, operator, edges, setNodes]);
|
||||
|
||||
return (
|
||||
<div className="borealis-node">
|
||||
<div style={{ position: "absolute", left: -16, top: 12, fontSize: "8px", color: "#ccc" }}>A</div>
|
||||
<div style={{ position: "absolute", left: -16, top: 50, fontSize: "8px", color: "#ccc" }}>B</div>
|
||||
<Handle type="target" position={Position.Left} id="a" style={{ top: 12 }} className="borealis-handle" />
|
||||
<Handle type="target" position={Position.Left} id="b" style={{ top: 50 }} className="borealis-handle" />
|
||||
|
||||
<div className="borealis-node-header">
|
||||
{data?.label || "Math Operation"}
|
||||
</div>
|
||||
|
||||
<div className="borealis-node-content">
|
||||
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
|
||||
Aggregates A and B inputs then performs operation.
|
||||
</div>
|
||||
|
||||
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
|
||||
Operator:
|
||||
</label>
|
||||
<select
|
||||
value={operator}
|
||||
onChange={(e) => setOperator(e.target.value)}
|
||||
style={dropdownStyle}
|
||||
>
|
||||
<option value="Add">Add</option>
|
||||
<option value="Subtract">Subtract</option>
|
||||
<option value="Multiply">Multiply</option>
|
||||
<option value="Divide">Divide</option>
|
||||
<option value="Average">Average</option>
|
||||
</select>
|
||||
|
||||
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
|
||||
Result:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={result}
|
||||
disabled
|
||||
style={resultBoxStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const dropdownStyle = {
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
width: "100%",
|
||||
marginBottom: "8px"
|
||||
};
|
||||
|
||||
const resultBoxStyle = {
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: "#2a2a2a",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
width: "100%"
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "MathNode",
|
||||
label: "Math Operation",
|
||||
description: `
|
||||
Perform Math on Aggregated Inputs
|
||||
|
||||
- A and B groups are independently summed
|
||||
- Performs: Add, Subtract, Multiply, Divide, or Average
|
||||
- Result = A <op> B
|
||||
- Emits result via BorealisValueBus every update tick
|
||||
`.trim(),
|
||||
content: "Perform Math Operations",
|
||||
component: MathNode
|
||||
};
|
@ -0,0 +1,113 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Adjust_Contrast.jsx
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { Handle, Position, useStore } from "reactflow";
|
||||
|
||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
||||
|
||||
const ContrastNode = ({ id }) => {
|
||||
const edges = useStore((state) => state.edges);
|
||||
const [contrast, setContrast] = useState(100);
|
||||
const valueRef = useRef("");
|
||||
const [renderValue, setRenderValue] = useState("");
|
||||
|
||||
const applyContrast = (base64Data, contrastVal) => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
const factor = (259 * (contrastVal + 255)) / (255 * (259 - contrastVal));
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = factor * (data[i] - 128) + 128;
|
||||
data[i + 1] = factor * (data[i + 1] - 128) + 128;
|
||||
data[i + 2] = factor * (data[i + 2] - 128) + 128;
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
|
||||
};
|
||||
|
||||
img.onerror = () => resolve(base64Data);
|
||||
img.src = `data:image/png;base64,${base64Data}`;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const inputEdge = edges.find(e => e.target === id);
|
||||
if (!inputEdge?.source) return;
|
||||
|
||||
const input = window.BorealisValueBus[inputEdge.source] ?? "";
|
||||
if (!input) return;
|
||||
|
||||
applyContrast(input, contrast).then((output) => {
|
||||
setRenderValue(output);
|
||||
window.BorealisValueBus[id] = output;
|
||||
});
|
||||
}, [contrast, edges, id]);
|
||||
|
||||
useEffect(() => {
|
||||
let interval = null;
|
||||
const tick = async () => {
|
||||
const edge = edges.find(e => e.target === id);
|
||||
const input = edge ? window.BorealisValueBus[edge.source] : "";
|
||||
|
||||
if (input && input !== valueRef.current) {
|
||||
const result = await applyContrast(input, contrast);
|
||||
valueRef.current = input;
|
||||
setRenderValue(result);
|
||||
window.BorealisValueBus[id] = result;
|
||||
}
|
||||
};
|
||||
|
||||
interval = setInterval(tick, window.BorealisUpdateRate);
|
||||
return () => clearInterval(interval);
|
||||
}, [id, contrast, edges]);
|
||||
|
||||
return (
|
||||
<div className="borealis-node">
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
<div className="borealis-node-header">Adjust Contrast</div>
|
||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
||||
<label>Contrast (1–255):</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="255"
|
||||
value={contrast}
|
||||
onChange={(e) => setContrast(parseInt(e.target.value) || 100)}
|
||||
style={{
|
||||
width: "100%",
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
marginTop: "4px"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "ContrastNode",
|
||||
label: "Adjust Contrast",
|
||||
description: "Modify contrast of base64 image using a contrast multiplier.",
|
||||
content: "Adjusts contrast of image using canvas pixel transform.",
|
||||
component: ContrastNode
|
||||
};
|
@ -0,0 +1,195 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_BW_Threshold.jsx
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Handle, Position, useStore } from "reactflow";
|
||||
|
||||
// Ensure BorealisValueBus exists
|
||||
if (!window.BorealisValueBus) {
|
||||
window.BorealisValueBus = {};
|
||||
}
|
||||
|
||||
if (!window.BorealisUpdateRate) {
|
||||
window.BorealisUpdateRate = 100;
|
||||
}
|
||||
|
||||
const BWThresholdNode = ({ id, data }) => {
|
||||
const edges = useStore((state) => state.edges);
|
||||
|
||||
// Attempt to parse threshold from data.value (if present),
|
||||
// otherwise default to 128.
|
||||
const initial = parseInt(data?.value, 10);
|
||||
const [threshold, setThreshold] = useState(
|
||||
isNaN(initial) ? 128 : initial
|
||||
);
|
||||
|
||||
const [renderValue, setRenderValue] = useState("");
|
||||
const valueRef = useRef("");
|
||||
const lastUpstreamRef = useRef("");
|
||||
|
||||
// If the node is reimported and data.value changes externally,
|
||||
// update the threshold accordingly.
|
||||
useEffect(() => {
|
||||
const newVal = parseInt(data?.value, 10);
|
||||
if (!isNaN(newVal)) {
|
||||
setThreshold(newVal);
|
||||
}
|
||||
}, [data?.value]);
|
||||
|
||||
const handleThresholdInput = (e) => {
|
||||
let val = parseInt(e.target.value, 10);
|
||||
if (isNaN(val)) {
|
||||
val = 128;
|
||||
}
|
||||
val = Math.max(0, Math.min(255, val));
|
||||
|
||||
// Keep the Node's data.value updated
|
||||
data.value = val;
|
||||
|
||||
setThreshold(val);
|
||||
window.BorealisValueBus[id] = val;
|
||||
};
|
||||
|
||||
const applyThreshold = async (base64Data, cutoff) => {
|
||||
if (!base64Data || typeof base64Data !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const dataArr = imageData.data;
|
||||
|
||||
for (let i = 0; i < dataArr.length; i += 4) {
|
||||
const avg = (dataArr[i] + dataArr[i + 1] + dataArr[i + 2]) / 3;
|
||||
const color = avg < cutoff ? 0 : 255;
|
||||
dataArr[i] = color;
|
||||
dataArr[i + 1] = color;
|
||||
dataArr[i + 2] = color;
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
|
||||
};
|
||||
|
||||
img.onerror = () => resolve(base64Data);
|
||||
img.src = "data:image/png;base64," + base64Data;
|
||||
});
|
||||
};
|
||||
|
||||
// Main polling logic
|
||||
useEffect(() => {
|
||||
let currentRate = window.BorealisUpdateRate;
|
||||
let intervalId = null;
|
||||
|
||||
const runNodeLogic = async () => {
|
||||
const inputEdge = edges.find(e => e.target === id);
|
||||
if (inputEdge?.source) {
|
||||
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
|
||||
|
||||
if (upstreamValue !== lastUpstreamRef.current) {
|
||||
const transformed = await applyThreshold(upstreamValue, threshold);
|
||||
lastUpstreamRef.current = upstreamValue;
|
||||
valueRef.current = transformed;
|
||||
setRenderValue(transformed);
|
||||
window.BorealisValueBus[id] = transformed;
|
||||
}
|
||||
} else {
|
||||
lastUpstreamRef.current = "";
|
||||
valueRef.current = "";
|
||||
setRenderValue("");
|
||||
window.BorealisValueBus[id] = "";
|
||||
}
|
||||
};
|
||||
|
||||
intervalId = setInterval(runNodeLogic, currentRate);
|
||||
|
||||
const monitor = setInterval(() => {
|
||||
const newRate = window.BorealisUpdateRate;
|
||||
if (newRate !== currentRate) {
|
||||
clearInterval(intervalId);
|
||||
currentRate = newRate;
|
||||
intervalId = setInterval(runNodeLogic, currentRate);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
clearInterval(monitor);
|
||||
};
|
||||
}, [id, edges, threshold]);
|
||||
|
||||
// Reapply when threshold changes (even if image didn't)
|
||||
useEffect(() => {
|
||||
const inputEdge = edges.find(e => e.target === id);
|
||||
if (!inputEdge?.source) {
|
||||
return;
|
||||
}
|
||||
|
||||
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
|
||||
if (!upstreamValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyThreshold(upstreamValue, threshold).then((result) => {
|
||||
valueRef.current = result;
|
||||
setRenderValue(result);
|
||||
window.BorealisValueBus[id] = result;
|
||||
});
|
||||
}, [threshold, edges, id]);
|
||||
|
||||
return (
|
||||
<div className="borealis-node">
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
|
||||
<div className="borealis-node-header">BW Threshold</div>
|
||||
|
||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
||||
<div style={{ marginBottom: "6px", color: "#ccc" }}>
|
||||
Threshold Strength (0–255):
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
value={threshold}
|
||||
onChange={handleThresholdInput}
|
||||
style={{
|
||||
width: "100%",
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
marginBottom: "6px"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "BWThresholdNode",
|
||||
label: "BW Threshold",
|
||||
description: `
|
||||
Black & White Threshold (Stateless)
|
||||
|
||||
- Converts a base64 image to black & white using a user-defined threshold value
|
||||
- Reapplies threshold when the number changes, even if image stays the same
|
||||
- Outputs a new base64 PNG with BW transformation
|
||||
`.trim(),
|
||||
content: "Applies black & white threshold to base64 image input.",
|
||||
component: BWThresholdNode
|
||||
};
|
@ -0,0 +1,135 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Convert_to_Grayscale.jsx
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { Handle, Position, useStore } from "reactflow";
|
||||
|
||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
||||
|
||||
const GrayscaleNode = ({ id }) => {
|
||||
const edges = useStore((state) => state.edges);
|
||||
const [grayscaleLevel, setGrayscaleLevel] = useState(100); // percentage (0–100)
|
||||
const [renderValue, setRenderValue] = useState("");
|
||||
const valueRef = useRef("");
|
||||
|
||||
const applyGrayscale = (base64Data, level) => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
const alpha = level / 100;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const avg = (r + g + b) / 3;
|
||||
|
||||
data[i] = r * (1 - alpha) + avg * alpha;
|
||||
data[i + 1] = g * (1 - alpha) + avg * alpha;
|
||||
data[i + 2] = b * (1 - alpha) + avg * alpha;
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
|
||||
};
|
||||
|
||||
img.onerror = () => resolve(base64Data);
|
||||
img.src = `data:image/png;base64,${base64Data}`;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const inputEdge = edges.find(e => e.target === id);
|
||||
if (!inputEdge?.source) return;
|
||||
|
||||
const input = window.BorealisValueBus[inputEdge.source] ?? "";
|
||||
if (!input) return;
|
||||
|
||||
applyGrayscale(input, grayscaleLevel).then((output) => {
|
||||
valueRef.current = input;
|
||||
setRenderValue(output);
|
||||
window.BorealisValueBus[id] = output;
|
||||
});
|
||||
}, [grayscaleLevel, edges, id]);
|
||||
|
||||
useEffect(() => {
|
||||
let interval = null;
|
||||
|
||||
const run = async () => {
|
||||
const edge = edges.find(e => e.target === id);
|
||||
const input = edge ? window.BorealisValueBus[edge.source] : "";
|
||||
|
||||
if (input && input !== valueRef.current) {
|
||||
const result = await applyGrayscale(input, grayscaleLevel);
|
||||
valueRef.current = input;
|
||||
setRenderValue(result);
|
||||
window.BorealisValueBus[id] = result;
|
||||
}
|
||||
};
|
||||
|
||||
interval = setInterval(run, window.BorealisUpdateRate);
|
||||
return () => clearInterval(interval);
|
||||
}, [id, edges, grayscaleLevel]);
|
||||
|
||||
const handleLevelChange = (e) => {
|
||||
let val = parseInt(e.target.value, 10);
|
||||
if (isNaN(val)) val = 100;
|
||||
val = Math.min(100, Math.max(0, val));
|
||||
setGrayscaleLevel(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="borealis-node">
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
<div className="borealis-node-header">Convert to Grayscale</div>
|
||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
||||
<label style={{ display: "block", marginBottom: "4px" }}>
|
||||
Grayscale Intensity (0–100):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value={grayscaleLevel}
|
||||
onChange={handleLevelChange}
|
||||
style={{
|
||||
width: "100%",
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "GrayscaleNode",
|
||||
label: "Convert to Grayscale",
|
||||
description: `
|
||||
Adjustable Grayscale Conversion
|
||||
|
||||
- Accepts base64 image input
|
||||
- Applies grayscale effect using a % level
|
||||
- 0% = no change, 100% = full grayscale
|
||||
- Outputs result downstream as base64
|
||||
`.trim(),
|
||||
content: "Convert image to grayscale with adjustable intensity.",
|
||||
component: GrayscaleNode
|
||||
};
|
@ -0,0 +1,90 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Export_Image.jsx
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Handle, Position, useReactFlow } from "reactflow";
|
||||
|
||||
const ExportImageNode = ({ id }) => {
|
||||
const { getEdges } = useReactFlow();
|
||||
const [imageData, setImageData] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const edges = getEdges();
|
||||
const inputEdge = edges.find(e => e.target === id);
|
||||
if (inputEdge) {
|
||||
const base64 = window.BorealisValueBus?.[inputEdge.source];
|
||||
if (typeof base64 === "string") {
|
||||
setImageData(base64);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [id, getEdges]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
const blob = await (async () => {
|
||||
const res = await fetch(`data:image/png;base64,${imageData}`);
|
||||
return await res.blob();
|
||||
})();
|
||||
|
||||
if (window.showSaveFilePicker) {
|
||||
try {
|
||||
const fileHandle = await window.showSaveFilePicker({
|
||||
suggestedName: "image.png",
|
||||
types: [{
|
||||
description: "PNG Image",
|
||||
accept: { "image/png": [".png"] }
|
||||
}]
|
||||
});
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
} catch (e) {
|
||||
console.warn("Save cancelled:", e);
|
||||
}
|
||||
} else {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "image.png";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="borealis-node">
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
<div className="borealis-node-header">Export Image</div>
|
||||
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
|
||||
Export upstream base64-encoded image data as a PNG on-disk.
|
||||
<button
|
||||
style={{
|
||||
marginTop: "6px",
|
||||
width: "100%",
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px"
|
||||
}}
|
||||
onClick={handleDownload}
|
||||
disabled={!imageData}
|
||||
>
|
||||
Download PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "ExportImageNode",
|
||||
label: "Export Image",
|
||||
description: "Lets the user download the base64 PNG image to disk.",
|
||||
content: "Save base64 PNG to disk as a file.",
|
||||
component: ExportImageNode
|
||||
};
|
@ -0,0 +1,132 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Image_Viewer.jsx
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Handle, Position, useReactFlow } from "reactflow";
|
||||
|
||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
||||
|
||||
const ImageViewerNode = ({ id, data }) => {
|
||||
const { getEdges } = useReactFlow();
|
||||
const [imageBase64, setImageBase64] = useState("");
|
||||
const [selectedType, setSelectedType] = useState("base64");
|
||||
|
||||
// Monitor upstream input and propagate to ValueBus
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const edges = getEdges();
|
||||
const inputEdge = edges.find(e => e.target === id);
|
||||
if (inputEdge) {
|
||||
const sourceId = inputEdge.source;
|
||||
const valueBus = window.BorealisValueBus || {};
|
||||
const value = valueBus[sourceId];
|
||||
if (typeof value === "string") {
|
||||
setImageBase64(value);
|
||||
window.BorealisValueBus[id] = value;
|
||||
}
|
||||
} else {
|
||||
setImageBase64("");
|
||||
window.BorealisValueBus[id] = "";
|
||||
}
|
||||
}, window.BorealisUpdateRate || 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [id, getEdges]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!imageBase64) return;
|
||||
const blob = await (await fetch(`data:image/png;base64,${imageBase64}`)).blob();
|
||||
|
||||
if (window.showSaveFilePicker) {
|
||||
try {
|
||||
const fileHandle = await window.showSaveFilePicker({
|
||||
suggestedName: "image.png",
|
||||
types: [{
|
||||
description: "PNG Image",
|
||||
accept: { "image/png": [".png"] }
|
||||
}]
|
||||
});
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
} catch (e) {
|
||||
console.warn("Save cancelled:", e);
|
||||
}
|
||||
} else {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "image.png";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="borealis-node">
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
<div className="borealis-node-header">Image Viewer</div>
|
||||
<div className="borealis-node-content">
|
||||
<label style={{ fontSize: "10px" }}>Data Type:</label>
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value)}
|
||||
style={{ width: "100%", fontSize: "9px", marginBottom: "6px" }}
|
||||
>
|
||||
<option value="base64">Base64 Encoded Image</option>
|
||||
</select>
|
||||
|
||||
{imageBase64 ? (
|
||||
<>
|
||||
<img
|
||||
src={`data:image/png;base64,${imageBase64}`}
|
||||
alt="Live"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid #333",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px"
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
width: "100%",
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px"
|
||||
}}
|
||||
>
|
||||
Export to PNG
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontSize: "9px", color: "#888", marginTop: "6px" }}>
|
||||
Waiting for image...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "Image_Viewer",
|
||||
label: "Image Viewer",
|
||||
description: `
|
||||
Displays base64 image and exports it
|
||||
|
||||
- Accepts upstream base64 image
|
||||
- Shows preview
|
||||
- Provides "Export to PNG" button
|
||||
- Outputs the same base64 to downstream
|
||||
`.trim(),
|
||||
content: "Visual preview of base64 image with optional PNG export.",
|
||||
component: ImageViewerNode
|
||||
};
|
@ -0,0 +1,175 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Upload_Image.jsx
|
||||
|
||||
/**
|
||||
* ==================================================
|
||||
* Borealis - Image Upload Node (Raw Base64 Output)
|
||||
* ==================================================
|
||||
*
|
||||
* COMPONENT ROLE:
|
||||
* This node lets the user upload an image file (JPG/JPEG/PNG),
|
||||
* reads it as a data URL, then strips off the "data:image/*;base64,"
|
||||
* prefix, storing only the raw base64 data in BorealisValueBus.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - No upstream connector (target handle) is provided.
|
||||
* - The raw base64 is pushed out to downstream nodes via source handle.
|
||||
* - Your viewer (or other downstream node) must prepend "data:image/png;base64,"
|
||||
* or the appropriate MIME string for display.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Handle, Position, useReactFlow } from "reactflow";
|
||||
|
||||
// Global Shared Bus for Node Data Propagation
|
||||
if (!window.BorealisValueBus) {
|
||||
window.BorealisValueBus = {};
|
||||
}
|
||||
|
||||
// Global Update Rate (ms) for All Data Nodes
|
||||
if (!window.BorealisUpdateRate) {
|
||||
window.BorealisUpdateRate = 100;
|
||||
}
|
||||
|
||||
const ImageUploadNode = ({ id, data }) => {
|
||||
const { setNodes } = useReactFlow();
|
||||
|
||||
const [renderValue, setRenderValue] = useState(data?.value || "");
|
||||
const valueRef = useRef(renderValue);
|
||||
|
||||
// Handler for file uploads
|
||||
const handleFileUpload = (event) => {
|
||||
console.log("handleFileUpload triggered for node:", id);
|
||||
|
||||
// Get the file list
|
||||
const files = event.target.files || event.currentTarget.files;
|
||||
if (!files || files.length === 0) {
|
||||
console.log("No files selected or files array is empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
if (!file) {
|
||||
console.log("File object not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Debugging info
|
||||
console.log("Selected file:", file.name, file.type, file.size);
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ["image/jpeg", "image/png"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
console.warn("Unsupported file type in node:", id, file.type);
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup FileReader
|
||||
const reader = new FileReader();
|
||||
reader.onload = (loadEvent) => {
|
||||
console.log("FileReader onload in node:", id);
|
||||
const base64DataUrl = loadEvent?.target?.result || "";
|
||||
|
||||
// Strip off the data:image/...;base64, prefix to store raw base64
|
||||
const rawBase64 = base64DataUrl.replace(/^data:image\/[a-zA-Z]+;base64,/, "");
|
||||
console.log("Raw Base64 (truncated):", rawBase64.substring(0, 50));
|
||||
|
||||
valueRef.current = rawBase64;
|
||||
setRenderValue(rawBase64);
|
||||
window.BorealisValueBus[id] = rawBase64;
|
||||
|
||||
// Update node data
|
||||
setNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === id
|
||||
? { ...n, data: { ...n.data, value: rawBase64 } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
};
|
||||
reader.onerror = (errorEvent) => {
|
||||
console.error("FileReader error in node:", id, errorEvent);
|
||||
};
|
||||
|
||||
// Read the file as a data URL
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// Poll-based output (no upstream)
|
||||
useEffect(() => {
|
||||
let currentRate = window.BorealisUpdateRate || 100;
|
||||
let intervalId = null;
|
||||
|
||||
const runNodeLogic = () => {
|
||||
// Simply emit current value (raw base64) to the bus
|
||||
window.BorealisValueBus[id] = valueRef.current;
|
||||
};
|
||||
|
||||
const startInterval = () => {
|
||||
intervalId = setInterval(runNodeLogic, currentRate);
|
||||
};
|
||||
|
||||
startInterval();
|
||||
|
||||
// Monitor for global update rate changes
|
||||
const monitor = setInterval(() => {
|
||||
const newRate = window.BorealisUpdateRate || 100;
|
||||
if (newRate !== currentRate) {
|
||||
currentRate = newRate;
|
||||
clearInterval(intervalId);
|
||||
startInterval();
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
clearInterval(monitor);
|
||||
};
|
||||
}, [id, setNodes]);
|
||||
|
||||
return (
|
||||
<div className="borealis-node" style={{ minWidth: "160px" }}>
|
||||
{/* No target handle because we don't accept upstream data */}
|
||||
<div className="borealis-node-header">
|
||||
{data?.label || "Raw Base64 Image Upload"}
|
||||
</div>
|
||||
|
||||
<div className="borealis-node-content">
|
||||
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
|
||||
{data?.content || "Upload a JPG/PNG, store only the raw base64 in ValueBus."}
|
||||
</div>
|
||||
|
||||
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
|
||||
Upload Image File
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png"
|
||||
onChange={handleFileUpload}
|
||||
style={{
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
width: "100%",
|
||||
marginBottom: "8px"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "ImageUploadNode_RawBase64", // Unique ID for the node type
|
||||
label: "Upload Image",
|
||||
description: `
|
||||
A node to upload an image (JPG/PNG) and store it in base64 format for later use downstream.
|
||||
`.trim(),
|
||||
content: "Upload an image, output only the raw base64 string.",
|
||||
component: ImageUploadNode
|
||||
};
|
@ -0,0 +1,295 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Macro Automation/Node_Macro_KeyPress.jsx
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Handle, Position } from "reactflow";
|
||||
import Keyboard from "react-simple-keyboard";
|
||||
import "react-simple-keyboard/build/css/index.css";
|
||||
|
||||
if (!window.BorealisValueBus) window.BorealisValueBus = {};
|
||||
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
|
||||
|
||||
/**
|
||||
* KeyPressNode:
|
||||
* - Full keyboard with SHIFT toggling
|
||||
* - Press F-keys, digits, letters, or symbols
|
||||
* - Single key stored, overlay closes
|
||||
* - SHIFT or CAPS toggles "default" <-> "shift"
|
||||
*/
|
||||
|
||||
const KeyPressNode = ({ id, data }) => {
|
||||
const [selectedWindow, setSelectedWindow] = useState(data?.selectedWindow || "");
|
||||
const [keyPressed, setKeyPressed] = useState(data?.keyPressed || "");
|
||||
const [intervalMs, setIntervalMs] = useState(data?.intervalMs || 1000);
|
||||
const [randomRangeEnabled, setRandomRangeEnabled] = useState(false);
|
||||
const [randomMin, setRandomMin] = useState(750);
|
||||
const [randomMax, setRandomMax] = useState(950);
|
||||
|
||||
// Keyboard overlay
|
||||
const [showKeyboard, setShowKeyboard] = useState(false);
|
||||
const [layoutName, setLayoutName] = useState("default");
|
||||
|
||||
// A simple set of Windows for demonstration
|
||||
const fakeWindows = ["Notepad", "Chrome", "Discord", "Visual Studio Code"];
|
||||
|
||||
// This function is triggered whenever the user taps a key on the virtual keyboard
|
||||
const onKeyPress = (button) => {
|
||||
// SHIFT or CAPS toggling:
|
||||
if (button === "{shift}" || button === "{lock}") {
|
||||
handleShift();
|
||||
return;
|
||||
}
|
||||
|
||||
// Example skip list: these won't be stored as final single key
|
||||
const skipKeys = [
|
||||
"{bksp}", "{space}", "{tab}", "{enter}", "{escape}",
|
||||
"{f1}", "{f2}", "{f3}", "{f4}", "{f5}", "{f6}",
|
||||
"{f7}", "{f8}", "{f9}", "{f10}", "{f11}", "{f12}",
|
||||
"{shift}", "{lock}"
|
||||
];
|
||||
|
||||
// If the pressed button is not in skipKeys, let's store it and close
|
||||
if (!skipKeys.includes(button)) {
|
||||
setKeyPressed(button);
|
||||
setShowKeyboard(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle between "default" layout and "shift" layout
|
||||
const handleShift = () => {
|
||||
setLayoutName((prev) => (prev === "default" ? "shift" : "default"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="borealis-node" style={{ minWidth: 240, position: "relative" }}>
|
||||
{/* React Flow Handles */}
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
<Handle type="source" position={Position.Right} className="borealis-handle" />
|
||||
|
||||
{/* Node Header */}
|
||||
<div className="borealis-node-header" style={{ position: "relative" }}>
|
||||
Key Press
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "12px",
|
||||
right: "6px",
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#333",
|
||||
border: "1px solid #222"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Node Content */}
|
||||
<div className="borealis-node-content">
|
||||
<div style={{ fontSize: "9px", color: "#ccc", marginBottom: "8px" }}>
|
||||
Sends keypress to selected window on trigger.
|
||||
</div>
|
||||
|
||||
{/* Window Selector */}
|
||||
<label>Window:</label>
|
||||
<select
|
||||
value={selectedWindow}
|
||||
onChange={(e) => setSelectedWindow(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">-- Choose --</option>
|
||||
{fakeWindows.map((win) => (
|
||||
<option key={win} value={win}>
|
||||
{win}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Key: "Select Key" button & readOnly input */}
|
||||
<label style={{ marginTop: "6px" }}>Key:</label>
|
||||
<div style={{ display: "flex", gap: "6px", alignItems: "center", marginBottom: "6px" }}>
|
||||
<button onClick={() => setShowKeyboard(true)} style={buttonStyle}>
|
||||
Select Key
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={keyPressed}
|
||||
disabled
|
||||
readOnly
|
||||
style={{
|
||||
...inputStyle,
|
||||
width: "60px",
|
||||
backgroundColor: "#2a2a2a",
|
||||
color: "#aaa",
|
||||
cursor: "default"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Interval Configuration */}
|
||||
<label>Fixed Interval (ms):</label>
|
||||
<input
|
||||
type="number"
|
||||
min="100"
|
||||
step="50"
|
||||
value={intervalMs}
|
||||
onChange={(e) => setIntervalMs(Number(e.target.value))}
|
||||
disabled={randomRangeEnabled}
|
||||
style={{
|
||||
...inputStyle,
|
||||
backgroundColor: randomRangeEnabled ? "#2a2a2a" : "#1e1e1e"
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Random Interval */}
|
||||
<label style={{ marginTop: "6px" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={randomRangeEnabled}
|
||||
onChange={(e) => setRandomRangeEnabled(e.target.checked)}
|
||||
style={{ marginRight: "6px" }}
|
||||
/>
|
||||
Randomize Interval (ms):
|
||||
</label>
|
||||
|
||||
{randomRangeEnabled && (
|
||||
<div style={{ display: "flex", gap: "4px", marginTop: "4px" }}>
|
||||
<input
|
||||
type="number"
|
||||
min="100"
|
||||
value={randomMin}
|
||||
onChange={(e) => setRandomMin(Number(e.target.value))}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="100"
|
||||
value={randomMax}
|
||||
onChange={(e) => setRandomMax(Number(e.target.value))}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Overlay */}
|
||||
{showKeyboard && (
|
||||
<div style={keyboardOverlay}>
|
||||
<div style={keyboardContainer}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#ccc",
|
||||
marginBottom: "6px",
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
Full Keyboard
|
||||
</div>
|
||||
<Keyboard
|
||||
onKeyPress={onKeyPress}
|
||||
layoutName={layoutName}
|
||||
theme="hg-theme-dark hg-layout-default"
|
||||
layout={{
|
||||
default: [
|
||||
"{escape} {f1} {f2} {f3} {f4} {f5} {f6} {f7} {f8} {f9} {f10} {f11} {f12}",
|
||||
"` 1 2 3 4 5 6 7 8 9 0 - = {bksp}",
|
||||
"{tab} q w e r t y u i o p [ ] \\",
|
||||
"{lock} a s d f g h j k l ; ' {enter}",
|
||||
"{shift} z x c v b n m , . / {shift}",
|
||||
"{space}"
|
||||
],
|
||||
shift: [
|
||||
"{escape} {f1} {f2} {f3} {f4} {f5} {f6} {f7} {f8} {f9} {f10} {f11} {f12}",
|
||||
"~ ! @ # $ % ^ & * ( ) _ + {bksp}",
|
||||
"{tab} Q W E R T Y U I O P { } |",
|
||||
"{lock} A S D F G H J K L : \" {enter}",
|
||||
"{shift} Z X C V B N M < > ? {shift}",
|
||||
"{space}"
|
||||
]
|
||||
}}
|
||||
display={{
|
||||
"{bksp}": "⌫",
|
||||
"{escape}": "esc",
|
||||
"{tab}": "tab",
|
||||
"{lock}": "caps",
|
||||
"{enter}": "enter",
|
||||
"{shift}": "shift",
|
||||
"{space}": "space",
|
||||
"{f1}": "F1",
|
||||
"{f2}": "F2",
|
||||
"{f3}": "F3",
|
||||
"{f4}": "F4",
|
||||
"{f5}": "F5",
|
||||
"{f6}": "F6",
|
||||
"{f7}": "F7",
|
||||
"{f8}": "F8",
|
||||
"{f9}": "F9",
|
||||
"{f10}": "F10",
|
||||
"{f11}": "F11",
|
||||
"{f12}": "F12"
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "center", marginTop: "8px" }}>
|
||||
<button onClick={() => setShowKeyboard(false)} style={buttonStyle}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* Basic styling objects */
|
||||
const inputStyle = {
|
||||
width: "100%",
|
||||
fontSize: "9px",
|
||||
padding: "4px",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
marginBottom: "6px"
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
fontSize: "9px",
|
||||
padding: "4px 8px",
|
||||
backgroundColor: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
cursor: "pointer"
|
||||
};
|
||||
|
||||
const keyboardOverlay = {
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
zIndex: 1000,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
};
|
||||
|
||||
const keyboardContainer = {
|
||||
backgroundColor: "#1e1e1e",
|
||||
padding: "16px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #444",
|
||||
zIndex: 1001,
|
||||
maxWidth: "650px"
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "Macro_KeyPress",
|
||||
label: "Key Press (GUI-ONLY)",
|
||||
description: `
|
||||
Press a single character or function key on a full keyboard overlay.
|
||||
Shift/caps toggles uppercase/symbols.
|
||||
F-keys are included, but pressing them won't store that value unless you remove them from "skip" logic, if desired.
|
||||
`,
|
||||
content: "Send Key Press to Foreground Window via Borealis Agent",
|
||||
component: KeyPressNode
|
||||
};
|
@ -0,0 +1,134 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Organization/Node_Backdrop_Group_Box.jsx
|
||||
|
||||
/**
|
||||
* ===========================================
|
||||
* Borealis - Backdrop Group Box Node
|
||||
* ===========================================
|
||||
*
|
||||
* COMPONENT ROLE:
|
||||
* This node functions as a backdrop or grouping box.
|
||||
* It's resizable and can be renamed by clicking its title.
|
||||
* It doesn't connect to other nodes or pass data<74>it's purely visual.
|
||||
*
|
||||
* BEHAVIOR:
|
||||
* - Allows renaming via single-click on the header text.
|
||||
* - Can be resized by dragging from the bottom-right corner.
|
||||
*
|
||||
* NOTE:
|
||||
* - No inputs/outputs: purely cosmetic for grouping and labeling.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Handle, Position } from "reactflow";
|
||||
import { ResizableBox } from "react-resizable";
|
||||
import "react-resizable/css/styles.css";
|
||||
|
||||
const BackdropGroupBoxNode = ({ id, data }) => {
|
||||
const [title, setTitle] = useState(data?.label || "Backdrop Group Box");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleTitleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleTitleChange = (e) => {
|
||||
const newTitle = e.target.value;
|
||||
setTitle(newTitle);
|
||||
window.BorealisValueBus[id] = newTitle;
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ pointerEvents: "auto" }}>
|
||||
<ResizableBox
|
||||
width={200}
|
||||
height={120}
|
||||
minConstraints={[120, 80]}
|
||||
maxConstraints={[600, 600]}
|
||||
resizeHandles={["se"]}
|
||||
className="borealis-node"
|
||||
handle={(h) => (
|
||||
<span
|
||||
className={`react-resizable-handle react-resizable-handle-${h}`}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: "rgba(44, 44, 44, 0.5)",
|
||||
border: "1px solid #3a3a3a",
|
||||
borderRadius: "4px",
|
||||
boxShadow: "0 0 5px rgba(88, 166, 255, 0.15)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
zIndex: 0
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={handleTitleClick}
|
||||
style={{
|
||||
backgroundColor: "rgba(35, 35, 35, 0.5)",
|
||||
padding: "6px 10px",
|
||||
fontWeight: "bold",
|
||||
fontSize: "10px",
|
||||
cursor: "pointer",
|
||||
userSelect: "none"
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleBlur}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
padding: "2px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
width: "100%"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span>{title}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "10px", fontSize: "9px", height: "100%" }}>
|
||||
{/* Empty space for grouping */}
|
||||
</div>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "BackdropGroupBoxNode",
|
||||
label: "Backdrop Group Box",
|
||||
description: `
|
||||
Resizable Grouping Node
|
||||
|
||||
- Purely cosmetic, for grouping related nodes
|
||||
- Resizable by dragging bottom-right corner
|
||||
- Rename by clicking on title bar
|
||||
`.trim(),
|
||||
content: "Use as a visual group label",
|
||||
component: BackdropGroupBoxNode
|
||||
};
|
145
Data/Server/WebUI/src/nodes/Reporting/Node_Export_to_CSV.jsx
Normal file
145
Data/Server/WebUI/src/nodes/Reporting/Node_Export_to_CSV.jsx
Normal file
@ -0,0 +1,145 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Reporting/Node_Export_to_CSV.jsx
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Handle, Position } from "reactflow";
|
||||
import { Button, Snackbar } from "@mui/material";
|
||||
|
||||
/**
|
||||
* ExportToCSVNode
|
||||
* ----------------
|
||||
* Simplified version:
|
||||
* - No output connector
|
||||
* - Removed "Export to Disk" checkbox
|
||||
* - Only function is export to disk (manual trigger)
|
||||
*/
|
||||
const ExportToCSVNode = ({ data }) => {
|
||||
const [exportPath, setExportPath] = useState("");
|
||||
const [appendMode, setAppendMode] = useState(false);
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleExportClick = () => setSnackbarOpen(true);
|
||||
const handleSnackbarClose = () => setSnackbarOpen(false);
|
||||
|
||||
const handlePathClick = async () => {
|
||||
if (window.showDirectoryPicker) {
|
||||
try {
|
||||
const dirHandle = await window.showDirectoryPicker();
|
||||
setExportPath(dirHandle.name || "Selected Directory");
|
||||
} catch (err) {
|
||||
console.warn("Directory Selection Cancelled:", err);
|
||||
}
|
||||
} else {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFakePicker = (event) => {
|
||||
const files = event.target.files;
|
||||
if (files.length > 0) {
|
||||
const fakePath = files[0].webkitRelativePath?.split("/")[0];
|
||||
setExportPath(fakePath || "Selected Folder");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="borealis-node">
|
||||
<Handle type="target" position={Position.Left} className="borealis-handle" />
|
||||
|
||||
<div className="borealis-node-header">
|
||||
{data.label}
|
||||
</div>
|
||||
|
||||
<div className="borealis-node-content">
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
{data.content}
|
||||
</div>
|
||||
|
||||
<label style={{ fontSize: "9px", display: "block", marginTop: "6px" }}>
|
||||
Export Path:
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "4px", alignItems: "center", marginBottom: "6px" }}>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={exportPath}
|
||||
placeholder="Click to Select Folder"
|
||||
onClick={handlePathClick}
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: "9px",
|
||||
padding: "3px",
|
||||
background: "#1e1e1e",
|
||||
color: "#ccc",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "2px",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleExportClick}
|
||||
sx={{
|
||||
fontSize: "9px",
|
||||
padding: "2px 8px",
|
||||
minWidth: "unset",
|
||||
borderColor: "#58a6ff",
|
||||
color: "#58a6ff"
|
||||
}}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label style={{ fontSize: "9px", display: "block", marginTop: "4px" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appendMode}
|
||||
onChange={(e) => setAppendMode(e.target.checked)}
|
||||
style={{ marginRight: "4px" }}
|
||||
/>
|
||||
Append CSV Data if Headers Match
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
webkitdirectory="true"
|
||||
directory=""
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFakePicker}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={1000}
|
||||
onClose={handleSnackbarClose}
|
||||
message="Feature Coming Soon..."
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
type: "ExportToCSVNode",
|
||||
label: "Export to CSV",
|
||||
description: `
|
||||
Reporting Node
|
||||
This node lets the user choose a folder to export CSV data to disk.
|
||||
|
||||
When the "Export" button is clicked, CSV content (from upstream logic) is intended to be saved
|
||||
to the selected directory. This is a placeholder for future file system interaction.
|
||||
|
||||
Inputs:
|
||||
- Structured Table Data (via upstream node)
|
||||
|
||||
Outputs:
|
||||
- None (writes directly to disk in future)
|
||||
`.trim(),
|
||||
content: "Export Input Data to CSV File",
|
||||
component: ExportToCSVNode
|
||||
};
|
Reference in New Issue
Block a user