Borealis/Data/Server/WebUI/src/Flow_Editor.jsx

586 lines
18 KiB
JavaScript

// //////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Flow_Editor.jsx
// Import Node Configuration Sidebar
import NodeConfigurationSidebar from "./Node_Configuration_Sidebar";
import React, { useState, useEffect, useCallback, useRef } from "react";
import ReactFlow, {
Background,
addEdge,
applyNodeChanges,
applyEdgeChanges,
useReactFlow
} from "reactflow";
import {
Menu,
MenuItem,
MenuList,
Slider,
Box
} from "@mui/material";
import {
Polyline as PolylineIcon,
DeleteForever as DeleteForeverIcon,
Edit as EditIcon
} from "@mui/icons-material";
import {
SketchPicker
} from "react-color";
import "reactflow/dist/style.css";
export default function FlowEditor({
flowId,
nodes,
edges,
setNodes,
setEdges,
nodeTypes,
categorizedNodes
}) {
// Node Configuration Sidebar State
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedNodeLabel, setSelectedNodeLabel] = useState(null);
useEffect(() => {
window.BorealisOpenDrawer = (label) => {
setSelectedNodeLabel(label);
setDrawerOpen(true);
};
return () => {
delete window.BorealisOpenDrawer;
};
}, [nodes]);
const wrapperRef = useRef(null);
const { project } = useReactFlow();
const [contextMenu, setContextMenu] = useState(null);
const [edgeContextMenu, setEdgeContextMenu] = useState(null);
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
const [showColorPicker, setShowColorPicker] = useState(false);
const [colorPickerMode, setColorPickerMode] = useState(null);
const [labelPadding, setLabelPadding] = useState([8, 4]);
const [labelBorderRadius, setLabelBorderRadius] = useState(4);
const [labelOpacity, setLabelOpacity] = useState(0.8);
const [tempColor, setTempColor] = useState({ hex: "#58a6ff" });
const [pickerPos, setPickerPos] = useState({ x: 0, y: 0 });
// helper-line state
// guides: array of { xFlow, xPx } or { yFlow, yPx } for stationary nodes
const [guides, setGuides] = useState([]);
// activeGuides: array of { xPx } or { yPx } to draw
const [activeGuides, setActiveGuides] = useState([]);
// store moving node flow-size on drag start
const movingFlowSize = useRef({ width: 0, height: 0 });
const edgeStyles = {
step: "step",
curved: "bezier",
straight: "straight",
smoothstep: "smoothstep"
};
const animationStyles = {
dashes: { animated: true, style: { strokeDasharray: "6 3" } },
dots: { animated: true, style: { strokeDasharray: "2 4" } },
none: { animated: false, style: {} }
};
// Compute edge-only guides and capture moving node flow-size
const computeGuides = useCallback((dragNode) => {
if (!wrapperRef.current) return;
const parentRect = wrapperRef.current.getBoundingClientRect();
// measure moving node in pixel space
const dragEl = wrapperRef.current.querySelector(
`.react-flow__node[data-id="${dragNode.id}"]`
);
if (dragEl) {
const dr = dragEl.getBoundingClientRect();
const relLeft = dr.left - parentRect.left;
const relTop = dr.top - parentRect.top;
const relRight = relLeft + dr.width;
const relBottom = relTop + dr.height;
// project pixel corners to flow coords
const pTL = project({ x: relLeft, y: relTop });
const pTR = project({ x: relRight, y: relTop });
const pBL = project({ x: relLeft, y: relBottom });
movingFlowSize.current = {
width: pTR.x - pTL.x,
height: pBL.y - pTL.y
};
}
const lines = [];
nodes.forEach((n) => {
if (n.id === dragNode.id) return;
const el = wrapperRef.current.querySelector(
`.react-flow__node[data-id="${n.id}"]`
);
if (!el) return;
const r = el.getBoundingClientRect();
const relLeft = r.left - parentRect.left;
const relTop = r.top - parentRect.top;
const relRight = relLeft + r.width;
const relBottom = relTop + r.height;
// project pixel to flow coords
const pTL = project({ x: relLeft, y: relTop });
const pTR = project({ x: relRight, y: relTop });
const pBL = project({ x: relLeft, y: relBottom });
// vertical guides: left edge, right edge
lines.push({ xFlow: pTL.x, xPx: relLeft });
lines.push({ xFlow: pTR.x, xPx: relRight });
// horizontal guides: top edge, bottom edge
lines.push({ yFlow: pTL.y, yPx: relTop });
lines.push({ yFlow: pBL.y, yPx: relBottom });
});
setGuides(lines);
}, [nodes, project]);
// Snap & show only matching guides within threshold during drag
const onNodeDrag = useCallback((_, node) => {
const threshold = 5;
let snapX = null, snapY = null;
const show = [];
const { width: fw, height: fh } = movingFlowSize.current;
guides.forEach((ln) => {
if (ln.xFlow != null) {
// moving left edge to stationary edges
if (Math.abs(node.position.x - ln.xFlow) < threshold) {
snapX = ln.xFlow;
show.push({ xPx: ln.xPx });
}
// moving right edge to stationary edges
else if (Math.abs(node.position.x + fw - ln.xFlow) < threshold) {
snapX = ln.xFlow - fw;
show.push({ xPx: ln.xPx });
}
}
if (ln.yFlow != null) {
// moving top edge
if (Math.abs(node.position.y - ln.yFlow) < threshold) {
snapY = ln.yFlow;
show.push({ yPx: ln.yPx });
}
// moving bottom edge
else if (Math.abs(node.position.y + fh - ln.yFlow) < threshold) {
snapY = ln.yFlow - fh;
show.push({ yPx: ln.yPx });
}
}
});
if (snapX !== null || snapY !== null) {
setNodes((nds) =>
applyNodeChanges(
[{
id: node.id,
type: "position",
position: {
x: snapX !== null ? snapX : node.position.x,
y: snapY !== null ? snapY : node.position.y
}
}],
nds
)
);
setActiveGuides(show);
} else {
setActiveGuides([]);
}
}, [guides, setNodes]);
const onDrop = useCallback((event) => {
event.preventDefault();
const type = event.dataTransfer.getData("application/reactflow");
if (!type) return;
const bounds = wrapperRef.current.getBoundingClientRect();
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top
});
const id = "node-" + Date.now();
const nodeMeta = Object.values(categorizedNodes).flat().find((n) => n.type === type);
const newNode = {
id,
type,
position,
data: {
label: nodeMeta?.label || type,
content: nodeMeta?.content
},
dragHandle: ".borealis-node-header"
};
setNodes((nds) => [...nds, newNode]);
}, [project, setNodes, categorizedNodes]);
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onConnect = useCallback((params) => {
setEdges((eds) =>
addEdge({
...params,
type: "bezier",
animated: true,
style: { strokeDasharray: "6 3", stroke: "#58a6ff" }
}, eds)
);
}, [setEdges]);
const onNodesChange = useCallback((changes) => {
setNodes((nds) => applyNodeChanges(changes, nds));
}, [setNodes]);
const onEdgesChange = useCallback((changes) => {
setEdges((eds) => applyEdgeChanges(changes, eds));
}, [setEdges]);
const handleRightClick = (e, node) => {
e.preventDefault();
setContextMenu({ mouseX: e.clientX + 2, mouseY: e.clientY - 6, nodeId: node.id });
};
const handleEdgeRightClick = (e, edge) => {
e.preventDefault();
setEdgeContextMenu({ edgeId: edge.id, mouseX: e.clientX + 2, mouseY: e.clientY - 6 });
setSelectedEdgeId(edge.id);
};
const changeEdgeType = (newType) => {
setEdges((eds) => eds.map((e) =>
e.id === selectedEdgeId ? { ...e, type: edgeStyles[newType] } : e
));
setEdgeContextMenu(null);
};
const changeEdgeAnimation = (newAnim) => {
setEdges((eds) => eds.map((e) => {
if (e.id !== selectedEdgeId) return e;
const strokeColor = e.style?.stroke || "#58a6ff";
const anim = animationStyles[newAnim] || {};
return {
...e,
animated: anim.animated,
style: { ...anim.style, stroke: strokeColor },
markerEnd: e.markerEnd ? { ...e.markerEnd, color: strokeColor } : undefined
};
}));
setEdgeContextMenu(null);
};
const handleColorChange = (color) => {
setEdges((eds) => eds.map((e) => {
if (e.id !== selectedEdgeId) return e;
const updated = { ...e };
if (colorPickerMode === "stroke") {
updated.style = { ...e.style, stroke: color.hex };
if (e.markerEnd) updated.markerEnd = { ...e.markerEnd, color: color.hex };
} else if (colorPickerMode === "labelText") {
updated.labelStyle = { ...e.labelStyle, fill: color.hex };
} else if (colorPickerMode === "labelBg") {
updated.labelBgStyle = { ...e.labelBgStyle, fill: color.hex, fillOpacity: labelOpacity };
}
return updated;
}));
};
const handleAddLabel = () => {
setEdges((eds) => eds.map((e) =>
e.id === selectedEdgeId ? { ...e, label: "New Label" } : e
));
setEdgeContextMenu(null);
};
const handleEditLabel = () => {
const newText = prompt("Enter label text:");
if (newText !== null) {
setEdges((eds) => eds.map((e) =>
e.id === selectedEdgeId ? { ...e, label: newText } : e
));
}
setEdgeContextMenu(null);
};
const handleRemoveLabel = () => {
setEdges((eds) => eds.map((e) =>
e.id === selectedEdgeId ? { ...e, label: undefined } : e
));
setEdgeContextMenu(null);
};
const handlePickColor = (mode) => {
setColorPickerMode(mode);
setTempColor({ hex: "#58a6ff" });
setPickerPos({ x: edgeContextMenu?.mouseX || 0, y: edgeContextMenu?.mouseY || 0 });
setShowColorPicker(true);
};
const applyLabelStyleExtras = () => {
setEdges((eds) => eds.map((e) =>
e.id === selectedEdgeId
? {
...e,
labelBgPadding: labelPadding,
labelBgStyle: {
...e.labelBgStyle,
fillOpacity: labelOpacity,
rx: labelBorderRadius,
ry: labelBorderRadius
}
}
: e
));
setEdgeContextMenu(null);
};
useEffect(() => {
const nodeCountEl = document.getElementById("nodeCount");
if (nodeCountEl) nodeCountEl.innerText = nodes.length;
}, [nodes]);
const selectedNode = nodes.find((n) => n.data?.label === selectedNodeLabel);
const nodeDef = selectedNode
? Object.values(categorizedNodes).flat().find((def) => def.type === selectedNode.type)
: null;
return (
<div
className="flow-editor-container"
ref={wrapperRef}
style={{ position: "relative" }}
>
<NodeConfigurationSidebar
drawerOpen={drawerOpen}
setDrawerOpen={setDrawerOpen}
title={selectedNode?.data?.label || ""}
nodeData={
selectedNode && nodeDef
? {
...nodeDef,
...selectedNode.data,
nodeId: selectedNode.id
}
: null
}
/>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeContextMenu={handleRightClick}
onEdgeContextMenu={handleEdgeRightClick}
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
edgeOptions={{ type: "bezier", animated: true, style: { strokeDasharray: "6 3", stroke: "#58a6ff" } }}
proOptions={{ hideAttribution: true }}
onNodeDragStart={(_, node) => computeGuides(node)}
onNodeDrag={onNodeDrag}
onNodeDragStop={() => { setGuides([]); setActiveGuides([]); }}
>
<Background id={flowId} variant="lines" gap={65} size={1} color="rgba(255,255,255,0.2)" />
</ReactFlow>
{/* helper lines */}
{activeGuides.map((ln, i) =>
ln.xPx != null ? (
<div
key={i}
className="helper-line helper-line-vertical"
style={{ left: ln.xPx + "px", top: 0 }}
/>
) : (
<div
key={i}
className="helper-line helper-line-horizontal"
style={{ top: ln.yPx + "px", left: 0 }}
/>
)
)}
<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={() => {
if (contextMenu?.nodeId) {
setEdges((eds) => eds.filter((e) =>
e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId
));
}
setContextMenu(null);
}}>
<PolylineIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
Disconnect All Edges
</MenuItem>
<MenuItem onClick={() => {
if (contextMenu?.nodeId) {
setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId));
setEdges((eds) => eds.filter((e) =>
e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId
));
}
setContextMenu(null);
}}>
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
Remove Node
</MenuItem>
</Menu>
<Menu
open={Boolean(edgeContextMenu)}
onClose={() => setEdgeContextMenu(null)}
anchorReference="anchorPosition"
anchorPosition={edgeContextMenu ? { top: edgeContextMenu.mouseY, left: edgeContextMenu.mouseX } : undefined}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
>
<MenuItem onClick={() => setEdges((eds) => eds.filter((e) => e.id !== selectedEdgeId))}>
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
Unlink Edge
</MenuItem>
<MenuItem>
Edge Styles
<MenuList>
<MenuItem onClick={() => changeEdgeType("step")}>Step</MenuItem>
<MenuItem onClick={() => changeEdgeType("curved")}>Curved</MenuItem>
<MenuItem onClick={() => changeEdgeType("straight")}>Straight</MenuItem>
<MenuItem onClick={() => changeEdgeType("smoothstep")}>Smoothstep</MenuItem>
</MenuList>
</MenuItem>
<MenuItem>
Animations
<MenuList>
<MenuItem onClick={() => changeEdgeAnimation("dashes")}>Dashes</MenuItem>
<MenuItem onClick={() => changeEdgeAnimation("dots")}>Dots</MenuItem>
<MenuItem onClick={() => changeEdgeAnimation("none")}>Solid Line</MenuItem>
</MenuList>
</MenuItem>
<MenuItem>
Label
<MenuList>
<MenuItem onClick={handleAddLabel}>Add</MenuItem>
<MenuItem onClick={handleRemoveLabel}>Remove</MenuItem>
<MenuItem onClick={handleEditLabel}>
<EditIcon sx={{ fontSize: 16, mr: 1 }} />
Edit
</MenuItem>
<MenuItem onClick={() => handlePickColor("labelText")}>Text Color</MenuItem>
<MenuItem onClick={() => handlePickColor("labelBg")}>Background Color</MenuItem>
<MenuItem>
Padding:
<input
type="text"
defaultValue={`${labelPadding[0]},${labelPadding[1]}`}
style={{ width: 80, marginLeft: 8 }}
onBlur={(e) => {
const parts = e.target.value.split(",").map((v) => parseInt(v.trim()));
if (parts.length === 2 && parts.every(Number.isFinite)) setLabelPadding(parts);
}}
/>
</MenuItem>
<MenuItem>
Radius:
<input
type="number"
min="0"
max="20"
defaultValue={labelBorderRadius}
style={{ width: 60, marginLeft: 8 }}
onBlur={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val)) setLabelBorderRadius(val);
}}
/>
</MenuItem>
<MenuItem>
Opacity:
<Box display="flex" alignItems="center" ml={1}>
<Slider
value={labelOpacity}
onChange={(_, v) => setLabelOpacity(v)}
step={0.05}
min={0}
max={1}
style={{ width: 100 }}
/>
<input
type="number"
step="0.05"
min="0"
max="1"
value={labelOpacity}
style={{ width: 60, marginLeft: 8 }}
onChange={(e) => {
const v = parseFloat(e.target.value);
if (!isNaN(v)) setLabelOpacity(v);
}}
/>
</Box>
</MenuItem>
<MenuItem onClick={applyLabelStyleExtras}>Apply Label Style Changes</MenuItem>
</MenuList>
</MenuItem>
<MenuItem onClick={() => handlePickColor("stroke")}>Color</MenuItem>
</Menu>
{showColorPicker && (
<div
style={{
position: "absolute",
top: pickerPos.y,
left: pickerPos.x,
zIndex: 9999,
background: "#1e1e1e",
padding: "10px",
borderRadius: "8px"
}}
>
<SketchPicker color={tempColor.hex} onChange={(c) => setTempColor(c)} />
<div style={{ marginTop: "10px", textAlign: "center" }}>
<button
onClick={() => {
handleColorChange(tempColor);
setShowColorPicker(false);
}}
style={{
backgroundColor: "#58a6ff",
color: "#121212",
border: "none",
padding: "6px 12px",
borderRadius: "4px",
cursor: "pointer",
fontWeight: "bold"
}}
>
Set Color
</button>
</div>
</div>
)}
</div>
);
}